mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(core): Wire Cipher to encryption key proxy for key rotation support (#29013)
This commit is contained in:
parent
0eb30c6ca7
commit
641d492d56
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
47
packages/core/src/encryption/encryption-key-proxy.ts
Normal file
47
packages/core/src/encryption/encryption-key-proxy.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user