From fdac2c85729e19be0fd18f6807a7f5f99dfca002 Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Thu, 14 Sep 2023 11:34:51 +0200 Subject: [PATCH] feat(core): Add rsa option to ssh key generation (#7154) PR adds a new field to the SourceControlPreferences as well as to the POST parameters for the `source-control/preferences` and `source-control/generate-key-pair` endpoints. Both now accept an optional string parameter `keyGeneratorType` of `'ed25519' | 'rsa'` Calling the `source-control/generate-key-pair` endpoint with the parameter set, it will also update the stored preferences accordingly (so that in the future new keys will use the same method) By default ed25519 is being used. The default may be changed using a new environment parameter: `N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE` which can be `rsa` or `ed25519` RSA keys are generated with a length of 4096 bytes. --- packages/cli/src/config/schema.ts | 9 ++++++++ .../sourceControl.controller.ee.ts | 9 +++++--- .../sourceControl/sourceControlHelper.ee.ts | 3 ++- .../sourceControlPreferences.service.ee.ts | 23 +++++++++++++++---- .../sourceControl/types/keyPairType.ts | 1 + .../sourceControl/types/requests.ts | 2 ++ .../types/sourceControlGenerateKeyPair.ts | 8 +++++++ .../types/sourceControlPreferences.ts | 6 +++++ .../environments/SourceControl.test.ts | 18 +++++++++++++++ packages/cli/test/unit/SourceControl.test.ts | 13 +++++++++-- 10 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/environments/sourceControl/types/keyPairType.ts create mode 100644 packages/cli/src/environments/sourceControl/types/sourceControlGenerateKeyPair.ts diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index a1df4f5116..6a67adfd53 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -1204,4 +1204,13 @@ export const schema = { env: 'N8N_AI_ENABLED', }, }, + + sourceControl: { + defaultKeyPairType: { + doc: 'Default SSH key type to use when generating SSH keys', + format: ['rsa', 'ed25519'] as const, + default: 'ed25519', + env: 'N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE', + }, + }, }; diff --git a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts index 0d24a628dc..66e60d617d 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts @@ -118,7 +118,7 @@ export class SourceControlController { ) { await this.sourceControlService.setBranch(sanitizedPreferences.branchName); } - if (sanitizedPreferences.branchColor || sanitizedPreferences.branchReadOnly !== undefined) { + if (sanitizedPreferences.branchColor ?? sanitizedPreferences.branchReadOnly !== undefined) { await this.sourceControlPreferencesService.setPreferences( { branchColor: sanitizedPreferences.branchColor, @@ -237,9 +237,12 @@ export class SourceControlController { @Authorized(['global', 'owner']) @Post('/generate-key-pair', { middlewares: [sourceControlLicensedMiddleware] }) - async generateKeyPair(): Promise { + async generateKeyPair( + req: SourceControlRequest.GenerateKeyPair, + ): Promise { try { - const result = await this.sourceControlPreferencesService.generateAndSaveKeyPair(); + const keyPairType = req.body.keyGeneratorType; + const result = await this.sourceControlPreferencesService.generateAndSaveKeyPair(keyPairType); return result; } catch (error) { throw new BadRequestError((error as { message: string }).message); diff --git a/packages/cli/src/environments/sourceControl/sourceControlHelper.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlHelper.ee.ts index 93ee873173..e5ffd44b17 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlHelper.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlHelper.ee.ts @@ -11,6 +11,7 @@ import { } from './constants'; import type { SourceControlledFile } from './types/sourceControlledFile'; import path from 'path'; +import type { KeyPairType } from './types/keyPairType'; export function stringContainsExpression(testString: string): boolean { return /^=.*\{\{.*\}\}/.test(testString); @@ -63,7 +64,7 @@ export function isSourceControlLicensed() { return license.isSourceControlLicensed(); } -export async function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') { +export async function generateSshKeyPair(keyType: KeyPairType) { const sshpk = await import('sshpk'); const keyPair: KeyPair = { publicKey: '', diff --git a/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts index 7e447ef576..ad99093196 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts @@ -19,6 +19,8 @@ import { SOURCE_CONTROL_PREFERENCES_DB_KEY, } from './constants'; import path from 'path'; +import type { KeyPairType } from './types/keyPairType'; +import config from '@/config'; @Service() export class SourceControlPreferencesService { @@ -86,9 +88,15 @@ export class SourceControlPreferencesService { * Will generate an ed25519 key pair and save it to the database and the file system * Note: this will overwrite any existing key pair */ - async generateAndSaveKeyPair(): Promise { + async generateAndSaveKeyPair(keyPairType?: KeyPairType): Promise { sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]); - const keyPair = await generateSshKeyPair('ed25519'); + if (!keyPairType) { + keyPairType = + this.getPreferences().keyGeneratorType ?? + (config.get('sourceControl.defaultKeyPairType') as KeyPairType) ?? + 'ed25519'; + } + const keyPair = await generateSshKeyPair(keyPairType); if (keyPair.publicKey && keyPair.privateKey) { try { await fsWriteFile(this.sshKeyName + '.pub', keyPair.publicKey, { @@ -100,6 +108,10 @@ export class SourceControlPreferencesService { throw Error(`Failed to save key pair: ${(error as Error).message}`); } } + // update preferences only after generating key pair to prevent endless loop + if (keyPairType !== this.getPreferences().keyGeneratorType) { + await this.setPreferences({ keyGeneratorType: keyPairType }); + } return this.getPreferences(); } @@ -146,8 +158,11 @@ export class SourceControlPreferencesService { ): Promise { sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]); if (!this.hasKeyPairFiles()) { - LoggerProxy.debug('No key pair files found, generating new pair'); - await this.generateAndSaveKeyPair(); + const keyPairType = + preferences.keyGeneratorType ?? + (config.get('sourceControl.defaultKeyPairType') as KeyPairType); + LoggerProxy.debug(`No key pair files found, generating new pair using type: ${keyPairType}`); + await this.generateAndSaveKeyPair(keyPairType); } this.sourceControlPreferences = preferences; if (saveToDb) { diff --git a/packages/cli/src/environments/sourceControl/types/keyPairType.ts b/packages/cli/src/environments/sourceControl/types/keyPairType.ts new file mode 100644 index 0000000000..f9e4cf2d91 --- /dev/null +++ b/packages/cli/src/environments/sourceControl/types/keyPairType.ts @@ -0,0 +1 @@ +export type KeyPairType = 'ed25519' | 'rsa'; diff --git a/packages/cli/src/environments/sourceControl/types/requests.ts b/packages/cli/src/environments/sourceControl/types/requests.ts index 5fa165b5be..e48cf94681 100644 --- a/packages/cli/src/environments/sourceControl/types/requests.ts +++ b/packages/cli/src/environments/sourceControl/types/requests.ts @@ -9,6 +9,7 @@ import type { SourceControlPullWorkFolder } from './sourceControlPullWorkFolder' import type { SourceControlDisconnect } from './sourceControlDisconnect'; import type { SourceControlSetReadOnly } from './sourceControlSetReadOnly'; import type { SourceControlGetStatus } from './sourceControlGetStatus'; +import type { SourceControlGenerateKeyPair } from './sourceControlGenerateKeyPair'; export declare namespace SourceControlRequest { type UpdatePreferences = AuthenticatedRequest<{}, {}, Partial, {}>; @@ -21,4 +22,5 @@ export declare namespace SourceControlRequest { type PushWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPushWorkFolder, {}>; type PullWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPullWorkFolder, {}>; type GetStatus = AuthenticatedRequest<{}, {}, {}, SourceControlGetStatus>; + type GenerateKeyPair = AuthenticatedRequest<{}, {}, SourceControlGenerateKeyPair, {}>; } diff --git a/packages/cli/src/environments/sourceControl/types/sourceControlGenerateKeyPair.ts b/packages/cli/src/environments/sourceControl/types/sourceControlGenerateKeyPair.ts new file mode 100644 index 0000000000..c625b1eb2b --- /dev/null +++ b/packages/cli/src/environments/sourceControl/types/sourceControlGenerateKeyPair.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString } from 'class-validator'; +import { KeyPairType } from './keyPairType'; + +export class SourceControlGenerateKeyPair { + @IsOptional() + @IsString() + readonly keyGeneratorType?: KeyPairType; +} diff --git a/packages/cli/src/environments/sourceControl/types/sourceControlPreferences.ts b/packages/cli/src/environments/sourceControl/types/sourceControlPreferences.ts index 970507cdfe..9b5c3a25ca 100644 --- a/packages/cli/src/environments/sourceControl/types/sourceControlPreferences.ts +++ b/packages/cli/src/environments/sourceControl/types/sourceControlPreferences.ts @@ -1,4 +1,5 @@ import { IsBoolean, IsHexColor, IsOptional, IsString } from 'class-validator'; +import { KeyPairType } from './keyPairType'; export class SourceControlPreferences { constructor(preferences: Partial | undefined = undefined) { @@ -28,6 +29,10 @@ export class SourceControlPreferences { @IsBoolean() readonly initRepo?: boolean; + @IsOptional() + @IsString() + readonly keyGeneratorType?: KeyPairType; + static fromJSON(json: Partial): SourceControlPreferences { return new SourceControlPreferences(json); } @@ -42,6 +47,7 @@ export class SourceControlPreferences { branchName: preferences.branchName ?? defaultPreferences.branchName, branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly, branchColor: preferences.branchColor ?? defaultPreferences.branchColor, + keyGeneratorType: preferences.keyGeneratorType ?? defaultPreferences.keyGeneratorType, }); } } diff --git a/packages/cli/test/integration/environments/SourceControl.test.ts b/packages/cli/test/integration/environments/SourceControl.test.ts index 1e4852d7ca..13ee4df1e3 100644 --- a/packages/cli/test/integration/environments/SourceControl.test.ts +++ b/packages/cli/test/integration/environments/SourceControl.test.ts @@ -5,6 +5,7 @@ import * as utils from '../shared/utils/'; import type { User } from '@db/entities/User'; import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; import Container from 'typedi'; +import config from '@/config'; import { License } from '@/License'; import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee'; import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; @@ -69,4 +70,21 @@ describe('GET /sourceControl/preferences', () => { expect(data[0].id).toBe('haQetoXq9GxHSkft'); }); }); + + test('refreshing key pairsshould return new rsa key', async () => { + config.set('sourceControl.defaultKeyPairType', 'rsa'); + await authOwnerAgent + .post(`/${SOURCE_CONTROL_API_ROOT}/generate-key-pair`) + .send() + .expect(200) + .expect((res) => { + expect( + Container.get(SourceControlPreferencesService).getPreferences().keyGeneratorType, + ).toBe('rsa'); + expect(res.body.data).toHaveProperty('publicKey'); + expect(res.body.data).toHaveProperty('keyGeneratorType'); + expect(res.body.data.keyGeneratorType).toBe('rsa'); + expect(res.body.data.publicKey).toContain('ssh-rsa'); + }); + }); }); diff --git a/packages/cli/test/unit/SourceControl.test.ts b/packages/cli/test/unit/SourceControl.test.ts index e33b67aa8f..d25678ea66 100644 --- a/packages/cli/test/unit/SourceControl.test.ts +++ b/packages/cli/test/unit/SourceControl.test.ts @@ -20,6 +20,7 @@ import { LoggerProxy } from 'n8n-workflow'; import { getLogger } from '@/Logger'; import { constants as fsConstants, accessSync } from 'fs'; import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile'; +import type { SourceControlPreferences } from '@/environments/sourceControl/types/sourceControlPreferences'; const pushResult: SourceControlledFile[] = [ { @@ -167,13 +168,21 @@ beforeAll(async () => { describe('Source Control', () => { it('should generate an SSH key pair', async () => { - const keyPair = await generateSshKeyPair(); + const keyPair = await generateSshKeyPair('ed25519'); expect(keyPair.privateKey).toBeTruthy(); expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); expect(keyPair.publicKey).toBeTruthy(); expect(keyPair.publicKey).toContain('ssh-ed25519'); }); + it('should generate an RSA key pair', async () => { + const keyPair = await generateSshKeyPair('rsa'); + expect(keyPair.privateKey).toBeTruthy(); + expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); + expect(keyPair.publicKey).toBeTruthy(); + expect(keyPair.publicKey).toContain('ssh-rsa'); + }); + it('should check for git and ssh folders and create them if required', async () => { const userFolder = UserSettings.getUserN8nFolderPath(); const sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER); @@ -242,7 +251,7 @@ describe('Source Control', () => { }); it('should class validate correct preferences', async () => { - const validPreferences = { + const validPreferences: Partial = { branchName: 'main', repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git', branchReadOnly: false,