fix(core): Register the encryption key provider before any encrypt or decrypt (no-changelog) (#37972)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Fitzek 2026-09-07 17:04:39 +00:00 committed by GitHub
parent a716209294
commit 93945cce5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 161 additions and 83 deletions

View File

@ -67,14 +67,29 @@ describe('CredentialsHelper', () => {
const dynamicCredentialProxy = new DynamicCredentialsProxy(mockLogger);
// Setup cipher for testing
const encryptionKeyProxy = new EncryptionKeyProxy();
const cipher = new Cipher(
mock<InstanceSettings>({ encryptionKey: 'test_key_for_testing' }),
new CipherAes256GCM(),
new CipherAes256CBC(),
new EncryptionKeyProxy(),
encryptionKeyProxy,
);
Container.set(Cipher, cipher);
// The default deployment: no rotation, so the active key is the legacy
// instance-key descriptor (no-prefix, instance-key-wrapped).
const legacyDescriptor = {
id: 'instance-key',
value: cipher.encryptDEKWithInstanceKey('test_key_for_testing'),
algorithm: 'aes-256-cbc' as const,
format: 'no-prefix' as const,
};
encryptionKeyProxy.setProvider({
getActiveKey: async () => legacyDescriptor,
getKeyById: async () => null,
getLegacyKey: async () => legacyDescriptor,
});
const credentialsHelper = new CredentialsHelper(
new CredentialTypes(mockNodesAndCredentials),
mock(),

View File

@ -17,6 +17,7 @@ import { mock } from 'vitest-mock-extended';
import { ActiveExecutions } from '@/active-executions';
import { DeprecationService } from '@/deprecation/deprecation.service';
import { EncryptionBootstrapService } from '@/encryption/encryption-bootstrap.service';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { ActivityEventRelay } from '@/events/relays/activity.event-relay';
import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
@ -66,6 +67,10 @@ const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
// BaseCommand.init() wires the encryption key provider; this command-init test
// does not exercise encryption, so keep the bootstrap a no-op.
mockInstance(EncryptionBootstrapService);
// minimal command for exercising BaseCommand.init():
// Execute.init() chains singletons that cannot init twice per process
class ReadOnlyCommand extends BaseCommand {}

View File

@ -32,6 +32,7 @@ import { Expression, UnexpectedError } from 'n8n-workflow';
import type { AbstractServer } from '@/abstract-server';
import * as CrashJournal from '@/crash-journal';
import { getDataDeduplicationService } from '@/deduplication';
import { EncryptionBootstrapService } from '@/encryption/encryption-bootstrap.service';
import { TestRunCleanupService } from '@/evaluation.ee/test-runner/test-run-cleanup.service.ee';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { ActivityEventRelay } from '@/events/relays/activity.event-relay';
@ -201,6 +202,11 @@ export abstract class BaseCommand<F = never> {
});
}
// Wire the encryption key provider (and seed keys on a seeding main) before
// anything encrypts or decrypts. This must run for every entrypoint —
// servers and one-off commands — since the cipher has no fallback path.
await Container.get(EncryptionBootstrapService).run();
if (process.env.EXECUTIONS_PROCESS === 'own') process.exit(-1);
if (

View File

@ -7,7 +7,7 @@ import { Cipher, InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { KeyManagerService } from '@/modules/encryption-key-manager/key-manager.service';
import { KeyManagerService } from '@/encryption/key-manager.service';
const makeKey = (overrides: Partial<DeploymentKey> = {}): DeploymentKey =>
({

View File

@ -2,7 +2,7 @@ import { mockInstance, testDb } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { Cipher, InstanceSettings } from 'n8n-core';
import { KeyManagerService } from '@/modules/encryption-key-manager/key-manager.service';
import { KeyManagerService } from '@/encryption/key-manager.service';
import { EncryptionBootstrapService } from '../encryption-bootstrap.service';

View File

@ -4,7 +4,7 @@ import { Container } from '@n8n/di';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EncryptionKeyController } from '@/modules/encryption-key-manager/encryption-key.controller';
import { KeyManagerService } from '@/modules/encryption-key-manager/key-manager.service';
import { KeyManagerService } from '@/encryption/key-manager.service';
const makeKey = (overrides: Partial<DeploymentKey> = {}): DeploymentKey =>
({

View File

@ -6,25 +6,20 @@ import { InstanceSettings } from 'n8n-core';
@BackendModule({ name: 'encryption-key-manager' })
export class EncryptionKeyManagerModule implements ModuleInterface {
async init() {
// Loading and seeding run on every instance, independent of the rotation
// flag: the whole fleet must hold the keys before any instance turns the
// flag on and starts using them.
await import('./key-manager.service.js');
const { isKeyRotationEnabled } = await import('./key-rotation-flag.js');
// Key seeding and provider wiring run early in BaseCommand for every
// entrypoint (including one-off commands), so the module only registers
// the management API. The API (list + rotate) stays behind the flag:
// rotating keys only makes sense once the rotation write path is enabled.
const { isKeyRotationEnabled } = await import('@/encryption/key-rotation-flag.js');
// The management API (list + rotate) stays behind the flag: rotating keys
// only makes sense once the rotation write path is enabled.
if (isKeyRotationEnabled() && Container.get(InstanceSettings).instanceType === 'main') {
await import('./encryption-key.controller.js');
}
const { EncryptionBootstrapService } = await import('./encryption-bootstrap.service.js');
await Container.get(EncryptionBootstrapService).run();
}
/** Settings exposed to the frontend under `/rest/module-settings`. */
async settings() {
const { isKeyRotationEnabled } = await import('./key-rotation-flag.js');
const { isKeyRotationEnabled } = await import('@/encryption/key-rotation-flag.js');
return { rotationEnabled: isKeyRotationEnabled() };
}
}

View File

@ -9,7 +9,7 @@ import { Body, Get, GlobalScope, Post, Query, RestController } from '@n8n/decora
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { KeyManagerService } from './key-manager.service';
import { KeyManagerService } from '@/encryption/key-manager.service';
function toResponseDto(row: DeploymentKey): EncryptionKey {
return {

View File

@ -65,9 +65,13 @@ describe('SourceControlExportService Integration', () => {
// Setup export directory for testing (no longer creating real directories)
exportDirectory = path.join(process.cwd(), 'test-exports-' + uuid());
// Mock the instance settings to use our test directory
// Mock the instance settings to use our test directory. Keep the real
// encryptionKey: the cipher and the test key provider both read it, so a
// mock function here breaks credential encrypt/decrypt.
const { encryptionKey } = Container.get(InstanceSettings);
mockInstance(InstanceSettings, {
n8nFolder: exportDirectory,
encryptionKey,
});
// Get the services from container (this will use real dependencies)

View File

@ -0,0 +1,29 @@
import 'reflect-metadata';
import { Container } from '@n8n/di';
import { Cipher, EncryptionKeyProxy, InstanceSettings } from 'n8n-core';
// The cipher has no fallback path: every encrypt/decrypt needs a key provider.
// Production wires one in `BaseCommand.init()`; the test harness has no equivalent,
// so register the default no-rotation provider here for every suite.
//
// The descriptor is the legacy instance-key row (no-prefix, instance-key-wrapped),
// matching `KeyManagerService.instanceKeyLegacyInfo()`. It resolves the cipher and
// instance key lazily, so it always tracks the configured key (real or mocked) and
// does nothing for suites that never encrypt. A suite that needs a different
// provider (rotation, key-not-found) overwrites this by calling `setProvider`.
const legacyDescriptor = async () => {
const cipher = Container.get(Cipher);
const { encryptionKey } = Container.get(InstanceSettings);
return {
id: 'instance-key',
value: cipher.encryptDEKWithInstanceKey(encryptionKey),
algorithm: 'aes-256-cbc' as const,
format: 'no-prefix' as const,
};
};
Container.get(EncryptionKeyProxy).setProvider({
getActiveKey: legacyDescriptor,
getLegacyKey: legacyDescriptor,
getKeyById: async () => null,
});

View File

@ -38,7 +38,12 @@ export const baseConfig = mergeConfig(createVitestConfigWithDecorators(), {
// would leak that state across files.
pool: 'forks',
globalSetup: ['./test/global-setup.ts'],
setupFiles: ['./test/setup-test-folder.ts', './test/setup-mocks.ts', './test/extend-expect.ts'],
setupFiles: [
'./test/setup-test-folder.ts',
'./test/setup-mocks.ts',
'./test/setup-encryption-key.ts',
'./test/extend-expect.ts',
],
// Vitest's default exclude does not cover dist; compiled test files left in
// dist would otherwise be collected and fail as CJS.
exclude: [...configDefaults.exclude, '**/dist/**'],

View File

@ -16,14 +16,29 @@ describe('Credentials', () => {
const nodeCredentials = { id: '123', name: 'Test Credential' };
const credentialType = 'testApi';
const encryptionKeyProxy = new EncryptionKeyProxy();
const cipher = new Cipher(
mock<InstanceSettings>({ encryptionKey: 'password' }),
new CipherAes256GCM(),
new CipherAes256CBC(),
new EncryptionKeyProxy(),
encryptionKeyProxy,
);
Container.set(Cipher, cipher);
// The default deployment: no rotation, so every key is the legacy instance-key
// descriptor (no-prefix, instance-key-wrapped), matching KeyManagerService.
const legacyDescriptor = {
id: 'instance-key',
value: cipher.encryptDEKWithInstanceKey('password'),
algorithm: 'aes-256-cbc' as const,
format: 'no-prefix' as const,
};
encryptionKeyProxy.setProvider({
getActiveKey: async () => legacyDescriptor,
getKeyById: async () => null,
getLegacyKey: async () => legacyDescriptor,
});
const setDataKey = async (credentials: Credentials, key: string, data: CredentialInformation) => {
let fullData;
try {
@ -77,7 +92,7 @@ describe('Credentials', () => {
test('should throw an error when decryption fails', async () => {
const credentials = new Credentials(nodeCredentials, credentialType);
credentials.data = '{"key": "already-decrypted-credentials-data" }';
credentials.data = 'already-decrypted-credentials-data';
await expect(credentials.getData()).rejects.toThrow(CREDENTIAL_ERRORS.DECRYPTION_FAILED);

View File

@ -235,14 +235,16 @@ describe('Cipher', () => {
});
it('should write the byte-compatible legacy format when the descriptor is no-prefix', async () => {
const legacyDescriptor = {
id: 'legacy',
value: cipher.encryptDEKWithInstanceKey(instanceKey),
algorithm: 'aes-256-cbc',
format: 'no-prefix' as const,
};
withProvider({
getActiveKey: async () => ({
id: 'legacy',
value: cipher.encryptDEKWithInstanceKey(instanceKey),
algorithm: 'aes-256-cbc',
format: 'no-prefix',
}),
getActiveKey: async () => legacyDescriptor,
getKeyById: async () => null,
getLegacyKey: async () => legacyDescriptor,
});
const encrypted = await cipher.encryptV2('legacy-write');
@ -302,9 +304,14 @@ describe('Cipher', () => {
expect(getKeyById).not.toHaveBeenCalled();
});
it('should decrypt unprefixed data with the instance key without consulting the provider', async () => {
it('should decrypt unprefixed data through the legacy key from the provider', async () => {
const getKeyById = vi.fn();
const getLegacyKey = vi.fn();
const getLegacyKey = vi.fn(async () => ({
id: 'legacy',
value: cipher.encryptDEKWithInstanceKey(instanceKey),
algorithm: 'aes-256-cbc',
format: 'no-prefix' as const,
}));
withProvider({ getKeyById, getLegacyKey });
const legacyCiphertext = cipher.encryptWithKey('legacy-data', instanceKey, 'aes-256-cbc');
@ -312,7 +319,7 @@ describe('Cipher', () => {
const decrypted = await cipher.decryptV2(legacyCiphertext);
expect(decrypted).toEqual('legacy-data');
expect(getKeyById).not.toHaveBeenCalled();
expect(getLegacyKey).not.toHaveBeenCalled();
expect(getLegacyKey).toHaveBeenCalled();
});
it('should bypass the provider when customEncryptionKey is provided', async () => {
@ -326,11 +333,13 @@ describe('Cipher', () => {
expect(getActiveKey).not.toHaveBeenCalled();
});
it('should fall back to the instance key when no provider is configured', async () => {
const encrypted = await cipher.encryptV2('no-provider');
expect(encrypted.includes(':')).toBe(false);
expect(await cipher.decryptV2(encrypted)).toEqual('no-provider');
expect(cipher.decryptWithInstanceKey(encrypted)).toEqual('no-provider');
it('should throw when no provider is configured', async () => {
await expect(cipher.encryptV2('no-provider')).rejects.toThrow(
'Encryption key provider is not configured',
);
await expect(cipher.decryptV2('some-data')).rejects.toThrow(
'Encryption key provider is not configured',
);
});
});
});

View File

@ -7,7 +7,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 { EncryptionKeyProxy, KeyInfo } from './encryption-key-proxy';
import { CipherAlgorithm } from './interface';
/**
@ -61,36 +61,34 @@ export class Cipher {
return this.encryptWithKey(plaintext, customEncryptionKey, 'aes-256-cbc');
}
if (this.encryptionKeyProxy.isConfigured()) {
const keyInfo = await this.encryptionKeyProxy.getActiveKey();
const keyInfo = await this.encryptionKeyProxy.getActiveKey();
if (keyInfo.format === 'no-prefix') {
// No-prefix output must stay byte-compatible with the pre-rotation
// format, which readers decrypt with the instance key directly.
if (!this.verifiedLegacyDescriptors.has(keyInfo)) {
if (
this.decryptDEKWithInstanceKey(keyInfo.value) !== this.instanceSettings.encryptionKey ||
keyInfo.algorithm !== 'aes-256-cbc'
) {
throw new UnexpectedError(
'A no-prefix encryption descriptor must resolve to the instance key',
);
}
this.verifiedLegacyDescriptors.add(keyInfo);
if (keyInfo.format === 'no-prefix') {
// No-prefix output must stay byte-compatible with the pre-rotation
// format, which readers decrypt with the instance key directly.
if (!this.verifiedLegacyDescriptors.has(keyInfo)) {
if (
this.decryptDEKWithInstanceKey(keyInfo.value) !== this.instanceSettings.encryptionKey ||
keyInfo.algorithm !== 'aes-256-cbc'
) {
throw new UnexpectedError(
'A no-prefix encryption descriptor must resolve to the instance key',
);
}
return this.encryptWithKey(plaintext, this.instanceSettings.encryptionKey, 'aes-256-cbc');
this.verifiedLegacyDescriptors.add(keyInfo);
}
const plaintextKey = this.decryptDEKWithInstanceKey(keyInfo.value);
const ciphertext = this.encryptWithKey(
plaintext,
plaintextKey,
keyInfo.algorithm as CipherAlgorithm,
);
return `${keyInfo.id}:${ciphertext}`;
}
return this.encryptWithKey(plaintext, this.instanceSettings.encryptionKey, 'aes-256-cbc');
const plaintextKey = this.decryptDEKWithInstanceKey(keyInfo.value);
const ciphertext = this.encryptWithKey(
plaintext,
plaintextKey,
keyInfo.algorithm as CipherAlgorithm,
);
if (keyInfo.format === 'no-prefix') {
return ciphertext;
}
return `${keyInfo.id}:${ciphertext}`;
}
/**
@ -103,25 +101,25 @@ export class Cipher {
return this.decryptWithKey(data, customEncryptionKey, 'aes-256-cbc');
}
if (this.encryptionKeyProxy.isConfigured()) {
const colonIdx = data.indexOf(':');
if (colonIdx !== -1) {
const keyId = data.slice(0, colonIdx);
if (KEY_ID_PATTERN.test(keyId)) {
const ciphertext = data.slice(colonIdx + 1);
const keyInfo = await this.encryptionKeyProxy.getKeyById(keyId);
if (!keyInfo) throw new UnexpectedError(`Encryption key not found: ${keyId}`);
const plaintextKey = this.decryptDEKWithInstanceKey(keyInfo.value);
return this.decryptWithKey(
ciphertext,
plaintextKey,
keyInfo.algorithm as CipherAlgorithm,
);
}
let keyInfo: KeyInfo | null = null;
let ciphertext = data;
const colonIdx = data.indexOf(':');
if (colonIdx !== -1) {
const keyId = data.slice(0, colonIdx);
if (KEY_ID_PATTERN.test(keyId)) {
ciphertext = data.slice(colonIdx + 1);
keyInfo = await this.encryptionKeyProxy.getKeyById(keyId);
if (!keyInfo) throw new UnexpectedError(`Encryption key not found: ${keyId}`);
}
} else {
keyInfo = await this.encryptionKeyProxy.getLegacyKey();
}
return this.decryptWithKey(data, this.instanceSettings.encryptionKey, 'aes-256-cbc');
if (!keyInfo) throw new UnexpectedError('Encryption key not found!');
const plaintextKey = this.decryptDEKWithInstanceKey(keyInfo.value);
return this.decryptWithKey(ciphertext, plaintextKey, keyInfo.algorithm as CipherAlgorithm);
}
/**

View File

@ -10,9 +10,10 @@ export interface IEncryptionKeyProvider {
getActiveKey(): Promise<KeyInfo>;
getKeyById(id: string): Promise<KeyInfo | null>;
/**
* The seeded legacy CBC row. Not on the decrypt path — no-prefix data
* decrypts with the instance key directly — but kept for later
* re-encryption tooling that must enumerate what the legacy key was.
* The seeded legacy CBC row. Resolves the no-prefix decrypt path: ciphertext
* without a key-id prefix is legacy data, and this key unwraps to the
* instance key it was encrypted with. The provider falls back to the
* instance key when the row is absent, so old data stays readable.
*/
getLegacyKey(): Promise<KeyInfo>;
}
@ -34,10 +35,6 @@ export class EncryptionKeyProxy {
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();