fix(core): Repair legacy-format data-encryption keys during bootstrap (backport to release-candidate/2.39.x) (#38142)

Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
This commit is contained in:
n8n-assistant[bot] 2026-09-08 17:26:32 +00:00 committed by GitHub
parent 63e2795e01
commit 514e20b83d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 253 additions and 1 deletions

View File

@ -130,6 +130,18 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
return await this.find({ where: { type } });
}
async findDataEncryptionKeys(): Promise<DeploymentKey[]> {
return await this.find({ where: { type: 'data_encryption' } });
}
async rewrapLegacyDataEncryptionValue(
id: string,
oldValue: string,
wrappedValue: string,
): Promise<void> {
await this.update({ id, type: 'data_encryption', value: oldValue }, { value: wrappedValue });
}
async findAndCountForList(
opts: ListDeploymentKeysOptions,
): Promise<{ items: DeploymentKey[]; count: number }> {

View File

@ -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);
});
});
});

View File

@ -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 () => {

View File

@ -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<InstanceSettings>({ encryptionKey }),
new CipherAes256GCM(),
new CipherAes256CBC(),
mock<EncryptionKeyProxy>(),
);
const makeRepairService = (encryptionKey = randomBytes(24).toString('base64')) => {
const repo = mock<DeploymentKeyRepository>();
const cipher = realCipher(encryptionKey);
const service = new KeyManagerService(
repo,
cipher,
mock<InstanceSettings>({ encryptionKey }),
mock<Logger>(),
);
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();
});
});
});

View File

@ -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) {

View File

@ -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<void> {
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).
*