mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
a716209294
commit
93945cce5d
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
({
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
@ -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 =>
|
||||
({
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
29
packages/cli/test/setup-encryption-key.ts
Normal file
29
packages/cli/test/setup-encryption-key.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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/**'],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user