feat(core): Wire Cipher to encryption key proxy for key rotation support (#29013)

This commit is contained in:
Stephen Wright 2026-04-27 13:09:04 +01:00 committed by GitHub
parent 0eb30c6ca7
commit 641d492d56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 244 additions and 4 deletions

View File

@ -7,7 +7,13 @@ import {
import { Container } from '@n8n/di';
import { EntityNotFoundError } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { type InstanceSettings, Cipher, CipherAes256GCM, CipherAes256CBC } from 'n8n-core';
import {
type InstanceSettings,
Cipher,
CipherAes256GCM,
CipherAes256CBC,
EncryptionKeyProxy,
} from 'n8n-core';
import type {
IAuthenticateGeneric,
ICredentialDataDecryptedObject,
@ -45,6 +51,7 @@ describe('CredentialsHelper', () => {
mock<InstanceSettings>({ encryptionKey: 'test_key_for_testing' }),
new CipherAes256GCM(),
new CipherAes256CBC(),
new EncryptionKeyProxy(),
);
Container.set(Cipher, cipher);

View File

@ -2,7 +2,7 @@ import type { CredentialsEntity, Project, SharedCredentials, User } from '@n8n/d
import { CredentialsRepository, GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { Cipher, CipherAes256GCM, CipherAes256CBC } from 'n8n-core';
import { Cipher, CipherAes256GCM, CipherAes256CBC, EncryptionKeyProxy } from 'n8n-core';
import type { InstanceSettings } from 'n8n-core';
import type { GenericValue, IDataObject, INodeProperties } from 'n8n-workflow';
@ -19,6 +19,7 @@ const cipher = new Cipher(
mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' }),
new CipherAes256GCM(),
new CipherAes256CBC(),
new EncryptionKeyProxy(),
);
Container.set(Cipher, cipher);

View File

@ -7,6 +7,7 @@ import { CREDENTIAL_ERRORS } from '@/constants';
import { CipherAes256CBC } from '@/encryption/aes-256-cbc';
import { CipherAes256GCM } from '@/encryption/aes-256-gcm';
import { Cipher } from '@/encryption/cipher';
import { EncryptionKeyProxy } from '@/encryption/encryption-key-proxy';
import type { InstanceSettings } from '@/instance-settings';
import { Credentials } from '../credentials';
@ -19,6 +20,7 @@ describe('Credentials', () => {
mock<InstanceSettings>({ encryptionKey: 'password' }),
new CipherAes256GCM(),
new CipherAes256CBC(),
new EncryptionKeyProxy(),
);
Container.set(Cipher, cipher);

View File

@ -4,6 +4,7 @@ import { InstanceSettings } from '@/instance-settings';
import { mockInstance } from '@test/utils';
import { Cipher } from '../cipher';
import { EncryptionKeyProxy, type IEncryptionKeyProvider } from '../encryption-key-proxy';
describe('Cipher', () => {
mockInstance(InstanceSettings, { encryptionKey: 'test_key' });
@ -165,4 +166,136 @@ describe('Cipher', () => {
);
});
});
describe('encryptV2 / decryptV2 (proxy-aware)', () => {
const instanceKeyForProxy = 'test_key';
const plaintextDataKey = '11'.repeat(32);
const encryptionKeyProxy = Container.get(EncryptionKeyProxy);
const withProvider = (provider: Partial<IEncryptionKeyProvider>) => {
encryptionKeyProxy.setProvider(provider as IEncryptionKeyProvider);
};
afterEach(() => {
jest.restoreAllMocks();
encryptionKeyProxy.setProvider(undefined);
});
it('should use active key and embed keyId prefix when proxy is registered and feature flag is on', async () => {
const keyId = 'test-uuid-1234';
const encryptedDataKey = cipher.encryptWithInstanceKey(plaintextDataKey);
withProvider({
getActiveKey: async () => ({
id: keyId,
value: encryptedDataKey,
algorithm: 'aes-256-gcm',
}),
getKeyById: async () => null,
getLegacyKey: async () => ({
id: 'legacy',
value: encryptedDataKey,
algorithm: 'aes-256-cbc',
}),
});
const originalFlag = process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION;
process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION = 'true';
try {
const encrypted = await cipher.encryptV2('hello');
expect(encrypted.startsWith(`${keyId}:`)).toBe(true);
const ciphertext = encrypted.slice(keyId.length + 1);
const decrypted = cipher.decryptWithKey(ciphertext, plaintextDataKey, 'aes-256-gcm');
expect(decrypted).toEqual('hello');
} finally {
process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION = originalFlag;
}
});
it('should decrypt using keyId from prefix when proxy is registered', async () => {
const keyId = 'test-uuid-5678';
const encryptedDataKey = cipher.encryptWithInstanceKey(plaintextDataKey);
const ciphertext = cipher.encryptWithKey('world', plaintextDataKey, 'aes-256-gcm');
const prefixed = `${keyId}:${ciphertext}`;
withProvider({
getActiveKey: async () => ({
id: keyId,
value: encryptedDataKey,
algorithm: 'aes-256-gcm',
}),
getKeyById: async (id: string) =>
id === keyId ? { id, value: encryptedDataKey, algorithm: 'aes-256-gcm' } : null,
getLegacyKey: async () => ({
id: 'legacy',
value: encryptedDataKey,
algorithm: 'aes-256-cbc',
}),
});
const originalFlag = process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION;
process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION = 'true';
try {
const decrypted = await cipher.decryptV2(prefixed);
expect(decrypted).toEqual('world');
} finally {
process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION = originalFlag;
}
});
it('should use legacy CBC key for unprefixed data when proxy is registered', async () => {
const encryptedDataKey = cipher.encryptWithInstanceKey(instanceKeyForProxy);
const legacyCiphertext = cipher.encryptWithKey(
'legacy-data',
instanceKeyForProxy,
'aes-256-cbc',
);
withProvider({
getActiveKey: async () => ({
id: 'active',
value: encryptedDataKey,
algorithm: 'aes-256-cbc',
}),
getKeyById: async () => null,
getLegacyKey: async () => ({
id: 'legacy',
value: encryptedDataKey,
algorithm: 'aes-256-cbc',
}),
});
const decrypted = await cipher.decryptV2(legacyCiphertext);
expect(decrypted).toEqual('legacy-data');
});
it('should bypass proxy when customEncryptionKey is provided', async () => {
const encryptedDataKey = cipher.encryptWithInstanceKey(plaintextDataKey);
withProvider({
getActiveKey: async () => ({
id: 'should-not-be-called',
value: encryptedDataKey,
algorithm: 'aes-256-gcm',
}),
getKeyById: async () => null,
getLegacyKey: async () => ({
id: 'should-not-be-called',
value: encryptedDataKey,
algorithm: 'aes-256-gcm',
}),
});
const originalFlag = process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION;
process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION = 'true';
try {
const encrypted = await cipher.encryptV2('bypass-test', 'custom-key');
expect(encrypted.includes(':')).toBe(false);
const decrypted = await cipher.decryptV2(encrypted, 'custom-key');
expect(decrypted).toEqual('bypass-test');
} finally {
process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION = originalFlag;
}
});
});
});

View File

@ -5,6 +5,7 @@ import { assertUnreachable } from '@/utils/assertions';
import { CipherAes256CBC } from './aes-256-cbc';
import { CipherAes256GCM } from './aes-256-gcm';
import { EncryptionKeyProxy } from './encryption-key-proxy';
import { CipherAlgorithm } from './interface';
@Service()
@ -13,15 +14,62 @@ export class Cipher {
private readonly instanceSettings: InstanceSettings,
private readonly cipherAES256GCM: CipherAes256GCM,
private readonly cipherAES256CBC: CipherAes256CBC,
private readonly encryptionKeyProxy: EncryptionKeyProxy,
) {}
encrypt(data: string | object, customEncryptionKey?: string) {
encrypt(data: string | object, customEncryptionKey?: string): string {
const key = customEncryptionKey ?? this.instanceSettings.encryptionKey;
const plaintext = typeof data === 'string' ? data : JSON.stringify(data);
return this.encryptWithKey(plaintext, key, 'aes-256-cbc');
}
decrypt(data: string, customEncryptionKey?: string) {
decrypt(data: string, customEncryptionKey?: string): string {
const key = customEncryptionKey ?? this.instanceSettings.encryptionKey;
return this.decryptWithKey(data, key, 'aes-256-cbc');
}
async encryptV2(data: string | object, customEncryptionKey?: string): Promise<string> {
const plaintext = typeof data === 'string' ? data : JSON.stringify(data);
if (
!customEncryptionKey &&
this.encryptionKeyProxy.isConfigured() &&
process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION === 'true'
) {
const keyInfo = await this.encryptionKeyProxy.getActiveKey();
const plaintextKey = this.decryptWithInstanceKey(keyInfo.value);
const ciphertext = this.encryptWithKey(
plaintext,
plaintextKey,
keyInfo.algorithm as CipherAlgorithm,
);
return `${keyInfo.id}:${ciphertext}`;
}
const key = customEncryptionKey ?? this.instanceSettings.encryptionKey;
return this.encryptWithKey(plaintext, key, 'aes-256-cbc');
}
async decryptV2(data: string, customEncryptionKey?: string): Promise<string> {
if (
!customEncryptionKey &&
this.encryptionKeyProxy.isConfigured() &&
process.env.N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION === 'true'
) {
const colonIdx = data.indexOf(':');
if (colonIdx !== -1) {
const keyId = data.slice(0, colonIdx);
const ciphertext = data.slice(colonIdx + 1);
const keyInfo = await this.encryptionKeyProxy.getKeyById(keyId);
if (!keyInfo) throw new Error(`Encryption key not found: ${keyId}`);
const plaintextKey = this.decryptWithInstanceKey(keyInfo.value);
return this.decryptWithKey(ciphertext, plaintextKey, keyInfo.algorithm as CipherAlgorithm);
}
const keyInfo = await this.encryptionKeyProxy.getLegacyKey();
const plaintextKey = this.decryptWithInstanceKey(keyInfo.value);
return this.decryptWithKey(data, plaintextKey, keyInfo.algorithm as CipherAlgorithm);
}
const key = customEncryptionKey ?? this.instanceSettings.encryptionKey;
return this.decryptWithKey(data, key, 'aes-256-cbc');
}

View File

@ -0,0 +1,47 @@
import { Service } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow';
export type KeyInfo = { id: string; value: string; algorithm: string };
export interface IEncryptionKeyProvider {
getActiveKey(): Promise<KeyInfo>;
getKeyById(id: string): Promise<KeyInfo | null>;
getLegacyKey(): Promise<KeyInfo>;
}
/**
* Bridge between `Cipher` (packages/core) and the key manager (packages/cli).
* Always registered in the DI container. `EncryptionKeyManagerModule` calls
* `setProvider()` at init time to wire up the concrete implementation without
* introducing a circular dependency.
*
* `value` in `KeyInfo` is the key material already encrypted with the instance key.
* Callers must `decryptWithInstanceKey()` before using it for data encryption.
*/
@Service()
export class EncryptionKeyProxy {
private provider: IEncryptionKeyProvider | undefined;
setProvider(provider: IEncryptionKeyProvider | undefined): void {
this.provider = provider;
}
isConfigured(): boolean {
return this.provider !== undefined;
}
async getActiveKey(): Promise<KeyInfo> {
if (!this.provider) throw new UnexpectedError('Encryption key provider is not configured');
return await this.provider.getActiveKey();
}
async getKeyById(id: string): Promise<KeyInfo | null> {
if (!this.provider) throw new UnexpectedError('Encryption key provider is not configured');
return await this.provider.getKeyById(id);
}
async getLegacyKey(): Promise<KeyInfo> {
if (!this.provider) throw new UnexpectedError('Encryption key provider is not configured');
return await this.provider.getLegacyKey();
}
}

View File

@ -2,3 +2,5 @@ export { Cipher } from './cipher';
export { CipherAes256GCM } from './aes-256-gcm';
export { CipherAes256CBC } from './aes-256-cbc';
export type { CipherAlgorithm } from './interface';
export { EncryptionKeyProxy } from './encryption-key-proxy';
export type { KeyInfo, IEncryptionKeyProvider } from './encryption-key-proxy';