diff --git a/packages/@n8n/db/src/repositories/deployment-key.repository.ts b/packages/@n8n/db/src/repositories/deployment-key.repository.ts index c5723387ce8..607bdac850d 100644 --- a/packages/@n8n/db/src/repositories/deployment-key.repository.ts +++ b/packages/@n8n/db/src/repositories/deployment-key.repository.ts @@ -130,6 +130,18 @@ export class DeploymentKeyRepository extends Repository { return await this.find({ where: { type } }); } + async findDataEncryptionKeys(): Promise { + return await this.find({ where: { type: 'data_encryption' } }); + } + + async rewrapLegacyDataEncryptionValue( + id: string, + oldValue: string, + wrappedValue: string, + ): Promise { + await this.update({ id, type: 'data_encryption', value: oldValue }, { value: wrappedValue }); + } + async findAndCountForList( opts: ListDeploymentKeysOptions, ): Promise<{ items: DeploymentKey[]; count: number }> { diff --git a/packages/cli/src/encryption/__tests__/encryption-bootstrap.service.integration.test.ts b/packages/cli/src/encryption/__tests__/encryption-bootstrap.service.integration.test.ts index 0695ef378c2..03794d51570 100644 --- a/packages/cli/src/encryption/__tests__/encryption-bootstrap.service.integration.test.ts +++ b/packages/cli/src/encryption/__tests__/encryption-bootstrap.service.integration.test.ts @@ -2,8 +2,10 @@ import { mockInstance, testDb } from '@n8n/backend-test-utils'; import { DeploymentKeyRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { Cipher, InstanceSettings } from 'n8n-core'; +import { randomBytes } from 'node:crypto'; import { EncryptionBootstrapService } from '../encryption-bootstrap.service'; +import { KeyManagerService } from '../key-manager.service'; const INSTANCE_ENCRYPTION_KEY = 'legacy-encryption-key'; @@ -124,4 +126,90 @@ describe('EncryptionBootstrapService (integration)', () => { } }); }); + + describe('legacy DEK repair', () => { + const seed = async (value: string) => { + const repo = Container.get(DeploymentKeyRepository); + await repo.save( + repo.create({ type: 'data_encryption', value, algorithm: 'aes-256-gcm', status: 'active' }), + ); + }; + + const onlyRow = async () => { + const rows = await Container.get(DeploymentKeyRepository).find({ + where: { type: 'data_encryption' }, + }); + expect(rows).toHaveLength(1); + return rows[0]; + }; + + // Seeds an inactive row and returns it — the partial unique index allows + // only one active row per type, so a multi-row test must stay inactive. + const seedRow = async (value: string) => { + const repo = Container.get(DeploymentKeyRepository); + return await repo.save( + repo.create({ + type: 'data_encryption', + value, + algorithm: 'aes-256-gcm', + status: 'inactive', + }), + ); + }; + + it.each<[string, (cipher: Cipher, rawKey: string) => string]>([ + ['raw 2.18.x', (_cipher, rawKey) => rawKey], + ['CBC 2.19.x', (cipher, rawKey) => cipher.encryptWithInstanceKey(rawKey)], + ])('repairs a seeded %s key losslessly and idempotently', async (_name, build) => { + const cipher = Container.get(Cipher); + const rawKey = randomBytes(32).toString('hex'); + await seed(build(cipher, rawKey)); + const km = Container.get(KeyManagerService); + + await km.repairLegacyDataEncryptionKeys(); + const afterFirst = await onlyRow(); + expect(cipher.decryptDEKWithInstanceKey(afterFirst.value)).toBe(rawKey); + + // A second run leaves the now-GCM value untouched. + await km.repairLegacyDataEncryptionKeys(); + const afterSecond = await onlyRow(); + expect(afterSecond.value).toBe(afterFirst.value); + }); + + it('repairs every legacy row in a mixed set and keeps going past a skipped row', async () => { + const cipher = Container.get(Cipher); + const repo = Container.get(DeploymentKeyRepository); + + const rawA = randomBytes(32).toString('hex'); + const rawB = randomBytes(32).toString('hex'); + const rawC = randomBytes(32).toString('hex'); + + const rawRow = await seedRow(rawA); + const gcmRow = await seedRow(cipher.encryptDEKWithInstanceKey(rawB)); + const cbcRow = await seedRow(cipher.encryptWithInstanceKey(rawC)); + + await Container.get(KeyManagerService).repairLegacyDataEncryptionKeys(); + + const rows = await repo.find({ where: { type: 'data_encryption' } }); + const byId = (id: string) => rows.find((r) => r.id === id)!; + + // Both legacy rows are now GCM-readable and recover their exact keys. + expect(cipher.decryptDEKWithInstanceKey(byId(rawRow.id).value)).toBe(rawA); + expect(cipher.decryptDEKWithInstanceKey(byId(cbcRow.id).value)).toBe(rawC); + // The already-GCM row is untouched, byte for byte. + expect(byId(gcmRow.id).value).toBe(gcmRow.value); + }); + + it('leaves a properly GCM-wrapped key untouched', async () => { + const cipher = Container.get(Cipher); + const rawKey = randomBytes(32).toString('hex'); + await seed(cipher.encryptDEKWithInstanceKey(rawKey)); + const before = await onlyRow(); + + await Container.get(KeyManagerService).repairLegacyDataEncryptionKeys(); + + const after = await onlyRow(); + expect(after.value).toBe(before.value); + }); + }); }); diff --git a/packages/cli/src/encryption/__tests__/encryption-bootstrap.service.test.ts b/packages/cli/src/encryption/__tests__/encryption-bootstrap.service.test.ts index 5d54660718d..d828337661a 100644 --- a/packages/cli/src/encryption/__tests__/encryption-bootstrap.service.test.ts +++ b/packages/cli/src/encryption/__tests__/encryption-bootstrap.service.test.ts @@ -10,6 +10,7 @@ describe('EncryptionBootstrapService', () => { beforeEach(() => { vi.clearAllMocks(); + keyManager.repairLegacyDataEncryptionKeys.mockResolvedValue(undefined); keyManager.bootstrapLegacyCbcKey.mockResolvedValue(undefined); keyManager.bootstrapGcmKey.mockResolvedValue(undefined); }); @@ -66,6 +67,15 @@ describe('EncryptionBootstrapService', () => { expect(encryptionKeyProxy.setProvider).toHaveBeenCalledWith(keyManager); }); + it('repairs legacy keys before seeding', async () => { + await createService().run(); + + expect(keyManager.repairLegacyDataEncryptionKeys).toHaveBeenCalled(); + expect(keyManager.repairLegacyDataEncryptionKeys.mock.invocationCallOrder[0]).toBeLessThan( + keyManager.bootstrapGcmKey.mock.invocationCallOrder[0], + ); + }); + it('bootstraps CBC before GCM', async () => { const order: string[] = []; keyManager.bootstrapLegacyCbcKey.mockImplementation(async () => { diff --git a/packages/cli/src/encryption/__tests__/key-manager.service.test.ts b/packages/cli/src/encryption/__tests__/key-manager.service.test.ts index 4e612829a51..0693a6cb3f5 100644 --- a/packages/cli/src/encryption/__tests__/key-manager.service.test.ts +++ b/packages/cli/src/encryption/__tests__/key-manager.service.test.ts @@ -3,7 +3,14 @@ import { mockInstance } from '@n8n/backend-test-utils'; import type { DeploymentKey } from '@n8n/db'; import { DeploymentKeyRepository } from '@n8n/db'; import { Container } from '@n8n/di'; -import { Cipher, InstanceSettings } from 'n8n-core'; +import { + Cipher, + CipherAes256CBC, + CipherAes256GCM, + type EncryptionKeyProxy, + InstanceSettings, +} from 'n8n-core'; +import { randomBytes } from 'node:crypto'; import { mock } from 'vitest-mock-extended'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -672,4 +679,74 @@ describe('KeyManagerService', () => { expect(repo.find).toHaveBeenCalledTimes(1); }); }); + + describe('repairLegacyDataEncryptionKeys()', () => { + // A real Cipher, so every legacy value is produced by real encryption and the + // re-wrap round-trips for real. Only the repository is mocked. + const realCipher = (encryptionKey: string) => + new Cipher( + mock({ encryptionKey }), + new CipherAes256GCM(), + new CipherAes256CBC(), + mock(), + ); + + const makeRepairService = (encryptionKey = randomBytes(24).toString('base64')) => { + const repo = mock(); + const cipher = realCipher(encryptionKey); + const service = new KeyManagerService( + repo, + cipher, + mock({ encryptionKey }), + mock(), + ); + return { service, repo, cipher }; + }; + + // A real, freshly generated DEK — never a hand-built constant. + const rawKey = randomBytes(32).toString('hex'); + + it.each<[string, (cipher: Cipher) => string]>([ + ['raw 2.18.x', () => rawKey], + ['CBC 2.19.x', (cipher) => cipher.encryptWithInstanceKey(rawKey)], + ])('recovers a %s key and re-wraps it as GCM', async (_name, build) => { + const { service, repo, cipher } = makeRepairService(); + repo.findDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'k', value: build(cipher) })]); + + await service.repairLegacyDataEncryptionKeys(); + + const [id, , wrapped] = repo.rewrapLegacyDataEncryptionValue.mock.calls[0]; + expect(id).toBe('k'); + // The re-wrapped value unwraps back to the exact same key: lossless. + expect(cipher.decryptDEKWithInstanceKey(wrapped)).toBe(rawKey); + }); + + it.each<[string, (cipher: Cipher, other: Cipher) => string]>([ + ['already GCM-wrapped', (cipher) => cipher.encryptDEKWithInstanceKey(rawKey)], + [ + 'CBC under a different instance key', + (_cipher, other) => other.encryptWithInstanceKey(rawKey), + ], + ['CBC that unwraps to a non-key', (cipher) => cipher.encryptWithInstanceKey('not-a-hex-key')], + ])('leaves a %s value untouched', async (_name, build) => { + const { service, repo, cipher } = makeRepairService(); + const other = realCipher(randomBytes(24).toString('base64')); + repo.findDataEncryptionKeys.mockResolvedValue([ + makeKey({ id: 'k', value: build(cipher, other) }), + ]); + + await service.repairLegacyDataEncryptionKeys(); + + expect(repo.rewrapLegacyDataEncryptionValue).not.toHaveBeenCalled(); + }); + + it('is a no-op when there are no data-encryption keys', async () => { + const { service, repo } = makeRepairService(); + repo.findDataEncryptionKeys.mockResolvedValue([]); + + await service.repairLegacyDataEncryptionKeys(); + + expect(repo.rewrapLegacyDataEncryptionValue).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/encryption/encryption-bootstrap.service.ts b/packages/cli/src/encryption/encryption-bootstrap.service.ts index fbc51428bd5..c8cf7ed7663 100644 --- a/packages/cli/src/encryption/encryption-bootstrap.service.ts +++ b/packages/cli/src/encryption/encryption-bootstrap.service.ts @@ -25,6 +25,7 @@ export class EncryptionBootstrapService { // the provider is wired for commands too. if (this.instanceSettings.instanceType === 'main' && this.canSeed) { try { + await this.keyManager.repairLegacyDataEncryptionKeys(); await this.keyManager.bootstrapLegacyCbcKey(this.instanceSettings.encryptionKey); await this.keyManager.bootstrapGcmKey(); } catch (error) { diff --git a/packages/cli/src/encryption/key-manager.service.ts b/packages/cli/src/encryption/key-manager.service.ts index 14aaaf55c55..bab97182c00 100644 --- a/packages/cli/src/encryption/key-manager.service.ts +++ b/packages/cli/src/encryption/key-manager.service.ts @@ -20,6 +20,12 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { isKeyRotationEnabled } from './key-rotation-flag'; +/** Raw DEK format: 64 hex chars stored directly as key material (n8n 2.18.x). */ +const RAW_DEK_PATTERN = /^[0-9a-f]{64}$/i; + +/** AES-256-CBC (OpenSSL Salted__) wrapped DEK, base64 prefix (n8n 2.19.x). */ +const CBC_WRAPPED_PREFIX = 'U2FsdGVk'; + /** * How long a process trusts its database view of the active key. A rotation * done on another instance becomes visible within this window — the accepted @@ -122,6 +128,64 @@ export class KeyManagerService implements IEncryptionKeyProvider { return keyInfo; } + /** + * Returns the raw DEK for a legacy-format value, or null if the value is already + * GCM-wrapped or cannot be recovered with this instance key. + */ + private recoverLegacyDek(keyInfo: DeploymentKey): string | null { + const { value } = keyInfo; + // 2.18.x: raw key material, used directly. Re-wrap as-is, no decrypt needed. + if (RAW_DEK_PATTERN.test(value)) { + return value; + } + + // 2.19.x: DEK wrapped with the instance key via AES-256-CBC. + if (value.startsWith(CBC_WRAPPED_PREFIX)) { + let recovered: string; + try { + recovered = this.cipher.decryptWithInstanceKey(value); + } catch { + this.logger.warn( + `Failed to decrypt legacy CBC-wrapped DEK ${keyInfo.id} with the instance key, this was probably wrapped with a different instance key`, + ); + return null; + } + + if (!RAW_DEK_PATTERN.test(recovered)) { + this.logger.warn(`Recovered legacy DEK ${keyInfo.id} is not valid raw key material`); + return null; + } + + return recovered; + } + + // Already GCM-wrapped or an unknown format: leave untouched. + return null; + } + + /** + * Re-wraps data-encryption keys stored in a pre-2.20 legacy format. Early key + * managers stored the DEK either as raw 64-hex key material (2.18.x) or wrapped + * with the instance key via AES-256-CBC (2.19.x). The current unwrap path expects + * a GCM blob, so both fail to decrypt. Both wrap the same 64-hex DEK; recovering + * it and re-wrapping with GCM restores the exact same key, so data stays readable. + * Idempotent: a GCM value matches neither legacy detector and is left untouched. + */ + async repairLegacyDataEncryptionKeys(): Promise { + const rows = await this.deploymentKeyRepository.findDataEncryptionKeys(); + for (const row of rows) { + const rawKey = this.recoverLegacyDek(row); + if (rawKey === null) continue; + const wrapped = this.cipher.encryptDEKWithInstanceKey(rawKey); + await this.deploymentKeyRepository.rewrapLegacyDataEncryptionValue( + row.id, + row.value, + wrapped, + ); + this.logger.info(`Re-wrapped legacy DEK ${row.id} with the instance key`); + } + } + /** * Returns the legacy CBC key used to decrypt old rows (no keyId prefix). *