mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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:
parent
63e2795e01
commit
514e20b83d
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user