feat(core): Add SSH key generation (#6006)

* basic prefs and ssh key generation

* review change

* cleanup save

* lint fix
This commit is contained in:
Michael Auerswald 2023-04-19 17:46:10 +02:00 committed by GitHub
parent 953198e092
commit 71ed1f410c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 313 additions and 19 deletions

View file

@ -97,6 +97,7 @@
"@types/replacestream": "^4.0.1", "@types/replacestream": "^4.0.1",
"@types/send": "^0.17.1", "@types/send": "^0.17.1",
"@types/shelljs": "^0.8.11", "@types/shelljs": "^0.8.11",
"@types/sshpk": "^1.17.1",
"@types/superagent": "4.1.13", "@types/superagent": "4.1.13",
"@types/swagger-ui-express": "^4.1.3", "@types/swagger-ui-express": "^4.1.3",
"@types/syslog-client": "^1.1.2", "@types/syslog-client": "^1.1.2",
@ -142,8 +143,8 @@
"curlconverter": "^3.0.0", "curlconverter": "^3.0.0",
"dotenv": "^8.0.0", "dotenv": "^8.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-handlebars": "^7.0.2",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"express-handlebars": "^7.0.2",
"express-openapi-validator": "^4.13.6", "express-openapi-validator": "^4.13.6",
"express-prom-bundle": "^6.6.0", "express-prom-bundle": "^6.6.0",
"fast-glob": "^3.2.5", "fast-glob": "^3.2.5",
@ -199,6 +200,7 @@
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"sse-channel": "^4.0.0", "sse-channel": "^4.0.0",
"sshpk": "^1.17.0",
"swagger-ui-express": "^4.3.0", "swagger-ui-express": "^4.3.0",
"syslog-client": "^1.1.1", "syslog-client": "^1.1.1",
"typedi": "^0.10.0", "typedi": "^0.10.0",

View file

@ -128,7 +128,7 @@ export class License {
return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES); return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES);
} }
isVersionControlEnabled() { isVersionControlLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.VERSION_CONTROL); return this.isFeatureEnabled(LICENSE_FEATURES.VERSION_CONTROL);
} }

View file

@ -160,7 +160,9 @@ import { variablesController } from './environments/variables.controller';
import { LdapManager } from './Ldap/LdapManager.ee'; import { LdapManager } from './Ldap/LdapManager.ee';
import { getVariablesLimit, isVariablesEnabled } from '@/environments/enviromentHelpers'; import { getVariablesLimit, isVariablesEnabled } from '@/environments/enviromentHelpers';
import { getCurrentAuthenticationMethod } from './sso/ssoHelpers'; import { getCurrentAuthenticationMethod } from './sso/ssoHelpers';
import { isVersionControlEnabled } from './environment/versionControl/versionControlHelper'; import { isVersionControlLicensed } from '@/environments/versionControl/versionControlHelper';
import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee';
import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee';
const exec = promisify(callbackExec); const exec = promisify(callbackExec);
@ -356,7 +358,7 @@ class Server extends AbstractServer {
saml: isSamlLicensed(), saml: isSamlLicensed(),
advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(), advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(),
variables: isVariablesEnabled(), variables: isVariablesEnabled(),
versionControl: isVersionControlEnabled(), versionControl: isVersionControlLicensed(),
}); });
if (isLdapEnabled()) { if (isLdapEnabled()) {
@ -393,6 +395,7 @@ class Server extends AbstractServer {
const mailer = Container.get(UserManagementMailer); const mailer = Container.get(UserManagementMailer);
const postHog = this.postHog; const postHog = this.postHog;
const samlService = Container.get(SamlService); const samlService = Container.get(SamlService);
const versionControlService = Container.get(VersionControlService);
const controllers: object[] = [ const controllers: object[] = [
new EventBusController(), new EventBusController(),
@ -421,6 +424,7 @@ class Server extends AbstractServer {
postHog, postHog,
}), }),
new SamlController(samlService), new SamlController(samlService),
new VersionControlController(versionControlService),
]; ];
if (isLdapEnabled()) { if (isLdapEnabled()) {
@ -545,12 +549,10 @@ class Server extends AbstractServer {
// initialize SamlService if it is licensed, even if not enabled, to // initialize SamlService if it is licensed, even if not enabled, to
// set up the initial environment // set up the initial environment
if (isSamlLicensed()) { try {
try { await Container.get(SamlService).init();
await Container.get(SamlService).init(); } catch (error) {
} catch (error) { LoggerProxy.warn(`SAML initialization failed: ${error.message}`);
LoggerProxy.error(`SAML initialization failed: ${error.message}`);
}
} }
// ---------------------------------------- // ----------------------------------------
@ -559,6 +561,18 @@ class Server extends AbstractServer {
this.app.use(`/${this.restEndpoint}/variables`, variablesController); this.app.use(`/${this.restEndpoint}/variables`, variablesController);
// ----------------------------------------
// Version Control
// ----------------------------------------
// initialize SamlService if it is licensed, even if not enabled, to
// set up the initial environment
try {
await Container.get(VersionControlService).init();
} catch (error) {
LoggerProxy.warn(`Version Control initialization failed: ${error.message}`);
}
// ---------------------------------------- // ----------------------------------------
// Returns parameter values which normally get loaded from an external API or // Returns parameter values which normally get loaded from an external API or

View file

@ -0,0 +1,18 @@
import Container from 'typedi';
import { License } from '../../License';
import { generateKeyPairSync } from 'crypto';
export function isVersionControlEnabled() {
const license = Container.get(License);
return license.isVersionControlLicensed();
}
export async function generateSshKeyPair() {
const keyPair = generateKeyPairSync('ed25519', {
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
publicKeyEncoding: { format: 'pem', type: 'spki' },
});
console.log(keyPair.privateKey);
console.log(keyPair.publicKey);
}

View file

@ -1,7 +0,0 @@
import Container from 'typedi';
import { License } from '../../License';
export function isVersionControlEnabled() {
const license = Container.get(License);
return license.isVersionControlEnabled();
}

View file

@ -0,0 +1 @@
export const VERSION_CONTROL_PREFERENCES_DB_KEY = 'features.versionControl';

View file

@ -0,0 +1,34 @@
import type { RequestHandler } from 'express';
import type { AuthenticatedRequest } from '@/requests';
import {
isVersionControlLicensed,
isVersionControlLicensedAndEnabled,
} from '../versionControlHelper';
export const versionControlLicensedOwnerMiddleware: RequestHandler = (
req: AuthenticatedRequest,
res,
next,
) => {
if (isVersionControlLicensed() && req.user?.globalRole.name === 'owner') {
next();
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
};
export const versionControlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) => {
if (isVersionControlLicensedAndEnabled()) {
next();
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
};
export const versionControlLicensedMiddleware: RequestHandler = (req, res, next) => {
if (isVersionControlLicensed()) {
next();
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
};

View file

@ -0,0 +1,4 @@
export interface KeyPair {
privateKey: string;
publicKey: string;
}

View file

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class VersionControlPreferences {
@IsString()
privateKey: string;
@IsString()
publicKey: string;
}

View file

@ -0,0 +1,22 @@
import { Get, RestController } from '../../decorators';
import {
versionControlLicensedMiddleware,
versionControlLicensedOwnerMiddleware,
} from './middleware/versionControlEnabledMiddleware';
import { VersionControlService } from './versionControl.service.ee';
@RestController('/versionControl')
export class VersionControlController {
constructor(private versionControlService: VersionControlService) {}
@Get('/preferences', { middlewares: [versionControlLicensedMiddleware] })
async getPreferences() {
return this.versionControlService.versionControlPreferences;
}
//TODO: temporary function to generate key and save new pair
@Get('/generateKeyPair', { middlewares: [versionControlLicensedOwnerMiddleware] })
async generateKeyPair() {
return this.versionControlService.generateAndSaveKeyPair();
}
}

View file

@ -0,0 +1,70 @@
import { Service } from 'typedi';
import { generateSshKeyPair } from './versionControlHelper';
import { VersionControlPreferences } from './types/versionControlPreferences';
import { VERSION_CONTROL_PREFERENCES_DB_KEY } from './constants';
import * as Db from '@/Db';
import { jsonParse } from 'n8n-workflow';
@Service()
export class VersionControlService {
private _versionControlPreferences: VersionControlPreferences = new VersionControlPreferences();
async init(): Promise<void> {
await this.loadFromDbAndApplyVersionControlPreferences();
}
public get versionControlPreferences(): VersionControlPreferences {
return {
...this._versionControlPreferences,
privateKey: '',
};
}
async generateAndSaveKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
const keyPair = generateSshKeyPair(keyType);
if (keyPair.publicKey && keyPair.privateKey) {
this.setPreferences({ ...keyPair });
await this.saveSamlPreferencesToDb();
}
return keyPair;
}
setPreferences(prefs: Partial<VersionControlPreferences>) {
this._versionControlPreferences = {
...this._versionControlPreferences,
...prefs,
};
}
async loadFromDbAndApplyVersionControlPreferences(): Promise<
VersionControlPreferences | undefined
> {
const loadedPrefs = await Db.collections.Settings.findOne({
where: { key: VERSION_CONTROL_PREFERENCES_DB_KEY },
});
if (loadedPrefs) {
try {
const prefs = jsonParse<VersionControlPreferences>(loadedPrefs.value);
if (prefs) {
this.setPreferences(prefs);
return prefs;
}
} catch {}
}
return;
}
async saveSamlPreferencesToDb(): Promise<VersionControlPreferences | undefined> {
const settingsValue = JSON.stringify(this._versionControlPreferences);
const result = await Db.collections.Settings.save({
key: VERSION_CONTROL_PREFERENCES_DB_KEY,
value: settingsValue,
loadOnStartup: true,
});
if (result)
try {
return jsonParse<VersionControlPreferences>(result.value);
} catch {}
return;
}
}

View file

@ -0,0 +1,53 @@
import Container from 'typedi';
import { License } from '../../License';
import { generateKeyPairSync } from 'crypto';
import sshpk from 'sshpk';
import type { KeyPair } from './types/keyPair';
export function isVersionControlLicensed() {
const license = Container.get(License);
return license.isVersionControlLicensed();
}
export function isVersionControlEnabled() {
// TODO: VERSION CONTROL check if enabled
return true;
}
export function isVersionControlLicensedAndEnabled() {
return isVersionControlLicensed() && isVersionControlEnabled();
}
export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
const keyPair: KeyPair = {
publicKey: '',
privateKey: '',
};
let generatedKeyPair: KeyPair;
switch (keyType) {
case 'ed25519':
generatedKeyPair = generateKeyPairSync('ed25519', {
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
publicKeyEncoding: { format: 'pem', type: 'spki' },
});
break;
case 'rsa':
generatedKeyPair = generateKeyPairSync('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
break;
}
const keyPublic = sshpk.parseKey(generatedKeyPair.publicKey, 'pem');
keyPair.publicKey = keyPublic.toString('ssh');
const keyPrivate = sshpk.parsePrivateKey(generatedKeyPair.privateKey, 'pem');
keyPair.privateKey = keyPrivate.toString('ssh-private');
return keyPair;
}

View file

@ -302,7 +302,9 @@ export class SamlService {
); );
} catch (error) { } catch (error) {
// throw error; // throw error;
throw new AuthError('SAML Authentication failed. Could not parse SAML response.'); throw new AuthError(
`SAML Authentication failed. Could not parse SAML response. ${(error as Error).message}`,
);
} }
const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult( const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult(
parsedSamlResponse, parsedSamlResponse,

View file

@ -0,0 +1,38 @@
import { Container } from 'typedi';
import type { SuperAgentTest } from 'supertest';
import type { User } from '@db/entities/User';
import { License } from '@/License';
import * as testDb from '../shared/testDb';
import * as utils from '../shared/utils';
import { VersionControlService } from '../../../src/environments/versionControl/versionControl.service.ee';
let owner: User;
let authOwnerAgent: SuperAgentTest;
beforeAll(async () => {
Container.get(License).isVersionControlLicensed = () => true;
const app = await utils.initTestServer({ endpointGroups: ['versionControl'] });
owner = await testDb.createOwner();
authOwnerAgent = utils.createAuthAgent(app)(owner);
});
afterAll(async () => {
await testDb.terminate();
});
describe('GET /versionControl/preferences', () => {
test('should return Version Control preferences', async () => {
await Container.get(VersionControlService).generateAndSaveKeyPair();
await authOwnerAgent
.get('/versionControl/preferences')
.expect(200)
.expect((res) => {
return (
'privateKey' in res.body &&
'publicKey' in res.body &&
res.body.publicKey.includes('ssh-ed25519') &&
res.body.privateKey.includes('BEGIN OPENSSH PRIVATE KEY')
);
});
});
});

View file

@ -23,6 +23,7 @@ type EndpointGroup =
| 'nodes' | 'nodes'
| 'ldap' | 'ldap'
| 'saml' | 'saml'
| 'versionControl'
| 'eventBus' | 'eventBus'
| 'license' | 'license'
| 'variables'; | 'variables';

View file

@ -82,6 +82,8 @@ import { SamlService } from '@/sso/saml/saml.service.ee';
import { SamlController } from '@/sso/saml/routes/saml.controller.ee'; import { SamlController } from '@/sso/saml/routes/saml.controller.ee';
import { EventBusController } from '@/eventbus/eventBus.controller'; import { EventBusController } from '@/eventbus/eventBus.controller';
import { License } from '@/License'; import { License } from '@/License';
import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee';
import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee';
export const mockInstance = <T>( export const mockInstance = <T>(
ctor: new (...args: any[]) => T, ctor: new (...args: any[]) => T,
@ -202,6 +204,14 @@ export async function initTestServer({
const samlService = Container.get(SamlService); const samlService = Container.get(SamlService);
registerController(testServer.app, config, new SamlController(samlService)); registerController(testServer.app, config, new SamlController(samlService));
break; break;
case 'versionControl':
const versionControlService = Container.get(VersionControlService);
registerController(
testServer.app,
config,
new VersionControlController(versionControlService),
);
break;
case 'nodes': case 'nodes':
registerController( registerController(
testServer.app, testServer.app,

View file

@ -0,0 +1,11 @@
import { generateSshKeyPair } from '../../src/environments/versionControl/versionControlHelper';
describe('Version Control', () => {
it('should generate an SSH key pair', () => {
const keyPair = generateSshKeyPair();
expect(keyPair.privateKey).toBeTruthy();
expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
expect(keyPair.publicKey).toBeTruthy();
expect(keyPair.publicKey).toContain('ssh-ed25519');
});
});

View file

@ -406,6 +406,9 @@ importers:
sse-channel: sse-channel:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
sshpk:
specifier: ^1.17.0
version: 1.17.0
swagger-ui-express: swagger-ui-express:
specifier: ^4.3.0 specifier: ^4.3.0
version: 4.5.0(express@4.18.2) version: 4.5.0(express@4.18.2)
@ -536,6 +539,9 @@ importers:
'@types/shelljs': '@types/shelljs':
specifier: ^0.8.11 specifier: ^0.8.11
version: 0.8.11 version: 0.8.11
'@types/sshpk':
specifier: ^1.17.1
version: 1.17.1
'@types/superagent': '@types/superagent':
specifier: 4.1.13 specifier: 4.1.13
version: 4.1.13 version: 4.1.13
@ -5959,7 +5965,6 @@ packages:
resolution: {integrity: sha512-5TMxIpYbIA9c1J0hYQjQDX3wr+rTgQEAXaW2BI8ECM8FO53wSW4HFZplTalrKSHuZUc76NtXcePRhwuOHqGD5g==} resolution: {integrity: sha512-5TMxIpYbIA9c1J0hYQjQDX3wr+rTgQEAXaW2BI8ECM8FO53wSW4HFZplTalrKSHuZUc76NtXcePRhwuOHqGD5g==}
dependencies: dependencies:
'@types/node': 16.18.12 '@types/node': 16.18.12
dev: false
/@types/aws4@1.11.2: /@types/aws4@1.11.2:
resolution: {integrity: sha512-x0f96eBPrCCJzJxdPbUvDFRva4yPpINJzTuXXpmS2j9qLUpF2nyGzvXPlRziuGbCsPukwY4JfuO+8xwsoZLzGw==} resolution: {integrity: sha512-x0f96eBPrCCJzJxdPbUvDFRva4yPpINJzTuXXpmS2j9qLUpF2nyGzvXPlRziuGbCsPukwY4JfuO+8xwsoZLzGw==}
@ -6882,6 +6887,13 @@ packages:
'@types/node': 16.18.12 '@types/node': 16.18.12
dev: true dev: true
/@types/sshpk@1.17.1:
resolution: {integrity: sha512-bOJek/W++DvWRNAeHmpvgX8Q1ypAq4nmeVi3nJ+pjDcMB214S8kSGkxRUw/Uz+zau4VwxcfNp0xUq4s/3DLjLw==}
dependencies:
'@types/asn1': 0.2.0
'@types/node': 16.18.12
dev: true
/@types/stack-utils@2.0.1: /@types/stack-utils@2.0.1:
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
dev: true dev: true