diff --git a/packages/field-encrypt/src/field-encryptor.ts b/packages/field-encrypt/src/field-encryptor.ts index 7ee91102..50f7e328 100644 --- a/packages/field-encrypt/src/field-encryptor.ts +++ b/packages/field-encrypt/src/field-encryptor.ts @@ -154,6 +154,51 @@ function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider { } } +/** + * No-op encryptor — stores/returns plaintext unchanged. + * + * Returned by createFieldEncryptor({ enabled: false, ... }). + * All repositories continue calling encrypt()/decrypt() without branching. + */ +export class NullFieldEncryptor extends FieldEncryptor { + constructor() { + // Use memory provider — it will never be called, but satisfies the constructor + super({ keyProvider: 'memory' }); + } + + override async encrypt(plaintext: string, _ctx: FieldEncryptContext): Promise { + // Return a sentinel object that looks encrypted but stores plaintext + return { + __encrypted: true, + v: 1, + alg: 'aes-256-gcm', + ct: plaintext, + iv: 'disabled', + tag: 'disabled', + dekId: 'disabled', + }; + } + + override async decrypt(field: EncryptedField, _ctx: FieldEncryptContext): Promise { + // If encryption was disabled, ct contains the plaintext directly + return field.ct; + } + + override async encryptBatch( + plaintexts: string[], + ctx: FieldEncryptContext + ): Promise { + return Promise.all(plaintexts.map(pt => this.encrypt(pt, ctx))); + } + + override async decryptBatch( + fields: EncryptedField[], + ctx: FieldEncryptContext + ): Promise { + return Promise.all(fields.map(f => this.decrypt(f, ctx))); + } +} + /** * Create a FieldEncryptor instance. * @@ -165,7 +210,18 @@ function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider { * keyVaultUrl: config.AZURE_KEYVAULT_URL, * }); * ``` + * + * To disable encryption globally or per-product: + * ```typescript + * const encryptor = createFieldEncryptor({ + * enabled: false, // ← no-op passthrough + * keyProvider: 'memory', + * }); + * ``` */ export function createFieldEncryptor(config: FieldEncryptorConfig): FieldEncryptor { + if (config.enabled === false) { + return new NullFieldEncryptor(); + } return new FieldEncryptor(config); } diff --git a/packages/field-encrypt/src/index.test.ts b/packages/field-encrypt/src/index.test.ts index 0e3b4408..aabd3140 100644 --- a/packages/field-encrypt/src/index.test.ts +++ b/packages/field-encrypt/src/index.test.ts @@ -10,6 +10,7 @@ import { randomBytes } from 'node:crypto'; import { createFieldEncryptor, FieldEncryptor, + NullFieldEncryptor, isEncryptedField, encryptField, decryptField, @@ -370,6 +371,67 @@ describe('createFieldEncryptor config', () => { }); }); +// ── Encryption toggle (enabled/disabled) ──────────── + +describe('enabled: false (NullFieldEncryptor)', () => { + const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' }; + + it('createFieldEncryptor returns NullFieldEncryptor when enabled=false', () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + expect(enc).toBeInstanceOf(NullFieldEncryptor); + }); + + it('createFieldEncryptor returns real FieldEncryptor when enabled=true', () => { + const enc = createFieldEncryptor({ enabled: true, keyProvider: 'memory' }); + expect(enc).not.toBeInstanceOf(NullFieldEncryptor); + expect(enc).toBeInstanceOf(FieldEncryptor); + }); + + it('createFieldEncryptor returns real FieldEncryptor when enabled is omitted', () => { + const enc = createFieldEncryptor({ keyProvider: 'memory' }); + expect(enc).not.toBeInstanceOf(NullFieldEncryptor); + }); + + it('encrypt returns sentinel with plaintext in ct field', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const result = await enc.encrypt('hello world', ctx); + expect(result.__encrypted).toBe(true); + expect(result.ct).toBe('hello world'); + expect(result.iv).toBe('disabled'); + expect(result.tag).toBe('disabled'); + expect(result.dekId).toBe('disabled'); + }); + + it('decrypt returns plaintext from ct field', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const encrypted = await enc.encrypt('secret', ctx); + const decrypted = await enc.decrypt(encrypted, ctx); + expect(decrypted).toBe('secret'); + }); + + it('encryptBatch returns sentinels for all items', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const results = await enc.encryptBatch(['a', 'b', 'c'], ctx); + expect(results).toHaveLength(3); + expect(results[0].ct).toBe('a'); + expect(results[1].ct).toBe('b'); + expect(results[2].ct).toBe('c'); + }); + + it('decryptBatch returns plaintexts from ct fields', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const encrypted = await enc.encryptBatch(['x', 'y'], ctx); + const decrypted = await enc.decryptBatch(encrypted, ctx); + expect(decrypted).toEqual(['x', 'y']); + }); + + it('isEncrypted returns true for disabled sentinel', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const result = await enc.encrypt('test', ctx); + expect(enc.isEncrypted(result)).toBe(true); + }); +}); + // ── Migration ─────────────────────────────────────── describe('migrateDocuments', () => { diff --git a/packages/field-encrypt/src/index.ts b/packages/field-encrypt/src/index.ts index 5c5d22c0..30a8a00f 100644 --- a/packages/field-encrypt/src/index.ts +++ b/packages/field-encrypt/src/index.ts @@ -25,7 +25,7 @@ */ // ── Main API ──────────────────────────────────────── -export { createFieldEncryptor, FieldEncryptor } from './field-encryptor.js'; +export { createFieldEncryptor, FieldEncryptor, NullFieldEncryptor } from './field-encryptor.js'; // ── Type guards ───────────────────────────────────── export { isEncryptedField } from './guards.js'; diff --git a/packages/field-encrypt/src/types.ts b/packages/field-encrypt/src/types.ts index e882f612..2273fcd8 100644 --- a/packages/field-encrypt/src/types.ts +++ b/packages/field-encrypt/src/types.ts @@ -67,6 +67,12 @@ export interface DekStore { /** Configuration for createFieldEncryptor(). */ export interface FieldEncryptorConfig { + /** + * Master toggle — set to false to disable encryption entirely. + * When disabled, encrypt() returns plaintext as-is and decrypt() passes through. + * Default: true. + */ + enabled?: boolean; /** Key provider type. */ keyProvider: KeyProviderType; /** Azure Key Vault URL (required for 'akv' provider). */ diff --git a/services/platform-service/src/modules/flags/seed.ts b/services/platform-service/src/modules/flags/seed.ts index 34a389ed..79f330e8 100644 --- a/services/platform-service/src/modules/flags/seed.ts +++ b/services/platform-service/src/modules/flags/seed.ts @@ -33,6 +33,14 @@ const COMMON_FLAGS: FlagSeedDef[] = [ platforms: [], percentage: 100, }, + { + key: 'encryption_enabled', + enabled: true, + description: + 'Field-level encryption at rest — disable to store plaintext (e.g., for debugging)', + platforms: [], + percentage: 100, + }, ]; const PRODUCT_FLAGS: Record = {