Skip to content

Commit df65c79

Browse files
feat(guardrails): vault-backed Presidio encryption keys (pure layer)
Secret-backed half of the advanced-anonymizers gap. Key material must never sit in the policy row (the module's own comment flagged this), but naively stripping it was a TRAP: normalize() silently downgraded a keyless encrypt spec to 'replace', so encryption would vanish on the persist→read round-trip. Fix: a KEYLESS encrypt spec is now the legal PRODUCTION shape meaning 'use the org's vaulted key'. validateOperatorSpec accepts it (a wrong-LENGTH inline key is still a hard error), so the intent survives persistence with no key material. New pure helpers: PRESIDIO_ENCRYPT_KEY_SECRET, policyUsesEncrypt, stripInlineEncryptKeys (what we persist), bindEncryptKey (binds the vaulted key at call time). FAIL SAFE: an unresolvable key DOWNGRADES to replace — the value is still masked, never plaintext — and the affected entities are reported, not hidden. A guard test proves an empty-key encrypt can never reach Presidio. 6 new unit tests (24/24 green); 2 existing assertions updated to the new, deliberate semantics.
1 parent 8de06c3 commit df65c79

2 files changed

Lines changed: 138 additions & 4 deletions

File tree

src/lib/presidio-anonymizers.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,12 @@ export function validateOperatorSpec(draft: unknown): OperatorValidation {
134134
}
135135

136136
case 'encrypt': {
137+
// A KEYLESS encrypt spec is legal and is the PRODUCTION shape: it means "use the org's
138+
// vault-held key", bound at call time by bindEncryptKey. Key material is therefore never
139+
// persisted in the policy row. An INLINE key is still accepted (bootstrap/tests) but must be a
140+
// real AES length; a wrong-length key is rejected rather than silently downgraded.
137141
const key = typeof d.key === 'string' ? d.key : '';
138-
if (!key) return { ok: false, error: 'encrypt requires a key' };
142+
if (!key) return { ok: true, value: { type: 'encrypt' } };
139143
const bytes = byteLength(key);
140144
if (!(VALID_ENCRYPT_KEY_BYTES as readonly number[]).includes(bytes)) {
141145
return {
@@ -360,3 +364,57 @@ export function describeOperator(spec: OperatorSpec): string {
360364
return 'replace';
361365
}
362366
}
367+
368+
// ─── Vault-backed encryption keys (the secret-backed half of advanced anonymizers) ────────────────
369+
// The rule: an AES key is SECRET MATERIAL and must never sit in the policy row. So the persisted
370+
// policy carries the encrypt INTENT only (a keyless encrypt spec) and the key is resolved from the
371+
// secrets store at call time. These are PURE so the whole rule is unit-testable with zero I/O.
372+
373+
/** Where the org's Presidio AES key lives in the secrets store (mirrors the Slack webhook pattern). */
374+
export const PRESIDIO_ENCRYPT_KEY_SECRET = 'org/presidio_encrypt_key';
375+
376+
/** Does this policy encrypt anywhere (⇒ a key must be resolved before it can run)? PURE. */
377+
export function policyUsesEncrypt(policy: AnonymizerPolicy): boolean {
378+
if (policy.default.type === 'encrypt') return true;
379+
return Object.values(policy.perEntity).some((spec) => spec.type === 'encrypt');
380+
}
381+
382+
/**
383+
* Drop inline AES key material from every encrypt spec — the shape we PERSIST. PURE. The encrypt
384+
* intent survives (a keyless encrypt spec is valid and means "use the vaulted key"), so stripping the
385+
* key can never silently disable encryption.
386+
*/
387+
export function stripInlineEncryptKeys(policy: AnonymizerPolicy): AnonymizerPolicy {
388+
const strip = (spec: OperatorSpec): OperatorSpec =>
389+
spec.type === 'encrypt' && spec.key !== undefined ? { type: 'encrypt' } : spec;
390+
const perEntity: Record<string, OperatorSpec> = {};
391+
for (const [entity, spec] of Object.entries(policy.perEntity)) perEntity[entity] = strip(spec);
392+
return { default: strip(policy.default), perEntity };
393+
}
394+
395+
/**
396+
* Bind the vaulted key into every encrypt spec, ready to send to Presidio. PURE.
397+
*
398+
* FAIL SAFE, never fail open: when no key can be resolved, an encrypt spec is DOWNGRADED to `replace`
399+
* (the value is still masked — it is never emitted in plaintext) and the affected entity is reported
400+
* in `downgraded` so the caller can surface the degradation honestly instead of hiding it. A spec that
401+
* already carries an inline key keeps it (bootstrap/tests).
402+
*/
403+
export function bindEncryptKey(
404+
policy: AnonymizerPolicy,
405+
key: string | null,
406+
): { policy: AnonymizerPolicy; downgraded: string[] } {
407+
const downgraded: string[] = [];
408+
const bind = (spec: OperatorSpec, label: string): OperatorSpec => {
409+
if (spec.type !== 'encrypt') return spec;
410+
if (spec.key) return spec;
411+
if (key) return { type: 'encrypt', key };
412+
downgraded.push(label);
413+
return { type: 'replace' };
414+
};
415+
const perEntity: Record<string, OperatorSpec> = {};
416+
for (const [entity, spec] of Object.entries(policy.perEntity)) {
417+
perEntity[entity] = bind(spec, entity);
418+
}
419+
return { policy: { default: bind(policy.default, 'DEFAULT'), perEntity }, downgraded };
420+
}

test/presidio-anonymizers.test.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'node:test';
33
import {
4+
bindEncryptKey,
5+
policyUsesEncrypt,
6+
stripInlineEncryptKeys,
47
ANONYMIZE_OPERATORS,
58
buildAnonymizeRequest,
69
byteLength,
@@ -118,9 +121,11 @@ test('validateOperatorSpec rejects bad operator + bad encrypt keys', () => {
118121
assert.equal(validateOperatorSpec(null).ok, false);
119122
assert.equal(validateOperatorSpec('string').ok, false);
120123

124+
// A KEYLESS encrypt spec is now the PRODUCTION shape — it means "use the org's vaulted key", so the
125+
// policy row never carries key material. It validates, carrying the intent with no key.
121126
const noKey = validateOperatorSpec({ type: 'encrypt' });
122-
assert.equal(noKey.ok, false);
123-
assert.match((noKey as { error: string }).error, /requires a key/);
127+
assert.equal(noKey.ok, true);
128+
assert.deepEqual((noKey as { value: unknown }).value, { type: 'encrypt' });
124129

125130
const shortKey = validateOperatorSpec({ type: 'encrypt', key: 'short' });
126131
assert.equal(shortKey.ok, false);
@@ -161,7 +166,8 @@ test('validateAnonymizerPolicy: missing default uses safe fallback; empty is val
161166
});
162167

163168
test('validateAnonymizerPolicy: bad default operator is a hard error', () => {
164-
const res = validateAnonymizerPolicy({ default: { type: 'encrypt' } });
169+
// A wrong-LENGTH inline key is still a hard error (a keyless encrypt is legal — vault-backed).
170+
const res = validateAnonymizerPolicy({ default: { type: 'encrypt', key: 'too-short' } });
165171
assert.equal(res.ok, false);
166172
assert.match((res as { error: string }).error, /default operator/);
167173
});
@@ -325,3 +331,73 @@ test('operator + hash catalogs match the verified live engine contract', () => {
325331
// The shipped BFSI default policy is itself valid.
326332
assert.equal(validateAnonymizerPolicy(DEFAULT_ANONYMIZER_POLICY).ok, true);
327333
});
334+
335+
// ─── Vault-backed encryption keys ─────────────────────────────────────────────────────────────────
336+
// The rule: key material never lives in the policy row. The persisted policy carries the encrypt
337+
// INTENT (keyless spec); the key is bound from the secrets store at call time; an unresolvable key
338+
// DOWNGRADES to replace (still masked — never plaintext) and is reported, never hidden.
339+
340+
test('policyUsesEncrypt detects encryption in the default or any per-entity spec', () => {
341+
assert.equal(policyUsesEncrypt({ default: { type: 'replace' }, perEntity: {} }), false);
342+
assert.equal(policyUsesEncrypt({ default: { type: 'encrypt' }, perEntity: {} }), true);
343+
assert.equal(
344+
policyUsesEncrypt({ default: { type: 'replace' }, perEntity: { IN_PAN: { type: 'encrypt' } } }),
345+
true,
346+
);
347+
});
348+
349+
test('stripInlineEncryptKeys removes key material but PRESERVES the encrypt intent', () => {
350+
const key = 'k'.repeat(32);
351+
const stripped = stripInlineEncryptKeys({
352+
default: { type: 'encrypt', key },
353+
perEntity: { IN_PAN: { type: 'encrypt', key }, EMAIL_ADDRESS: { type: 'mask', charsToMask: 4 } },
354+
});
355+
assert.deepEqual(stripped.default, { type: 'encrypt' }, 'default keeps encrypt, loses the key');
356+
assert.deepEqual(stripped.perEntity.IN_PAN, { type: 'encrypt' });
357+
assert.equal(stripped.perEntity.EMAIL_ADDRESS.charsToMask, 4, 'other operators untouched');
358+
// The stripped shape must survive a persist→read round-trip as ENCRYPT (not silently downgraded).
359+
assert.equal(normalizeAnonymizerPolicy(stripped).perEntity.IN_PAN.type, 'encrypt');
360+
});
361+
362+
test('bindEncryptKey injects the vaulted key into every keyless encrypt spec', () => {
363+
const key = 'v'.repeat(24);
364+
const { policy, downgraded } = bindEncryptKey(
365+
{ default: { type: 'encrypt' }, perEntity: { IN_PAN: { type: 'encrypt' }, X: { type: 'redact' } } },
366+
key,
367+
);
368+
assert.deepEqual(policy.default, { type: 'encrypt', key });
369+
assert.deepEqual(policy.perEntity.IN_PAN, { type: 'encrypt', key });
370+
assert.deepEqual(policy.perEntity.X, { type: 'redact' }, 'non-encrypt specs pass through');
371+
assert.deepEqual(downgraded, [], 'nothing degraded when the key resolves');
372+
});
373+
374+
test('bindEncryptKey FAILS SAFE: no key ⇒ downgrade to replace (masked, never plaintext) + report', () => {
375+
const { policy, downgraded } = bindEncryptKey(
376+
{ default: { type: 'replace' }, perEntity: { IN_PAN: { type: 'encrypt' }, AADHAAR: { type: 'encrypt' } } },
377+
null,
378+
);
379+
assert.deepEqual(policy.perEntity.IN_PAN, { type: 'replace' }, 'still masked, not passed through');
380+
assert.deepEqual(policy.perEntity.AADHAAR, { type: 'replace' });
381+
assert.deepEqual(downgraded.sort(), ['AADHAAR', 'IN_PAN'], 'the degradation is reported honestly');
382+
// and the produced Presidio operator config is a real masking operator, never an empty-key encrypt
383+
assert.deepEqual(specToOperatorConfig(policy.perEntity.IN_PAN), { type: 'replace' });
384+
});
385+
386+
test('bindEncryptKey keeps an inline key (bootstrap) in preference to the vaulted one', () => {
387+
const inline = 'i'.repeat(16);
388+
const { policy, downgraded } = bindEncryptKey(
389+
{ default: { type: 'encrypt', key: inline }, perEntity: {} },
390+
'v'.repeat(32),
391+
);
392+
assert.deepEqual(policy.default, { type: 'encrypt', key: inline });
393+
assert.deepEqual(downgraded, []);
394+
});
395+
396+
test('a keyless encrypt policy never reaches Presidio with an empty key', () => {
397+
// The regression this guards: specToOperatorConfig({type:'encrypt'}) yields key:'' which Presidio
398+
// rejects; bindEncryptKey must resolve or downgrade FIRST, so an empty key can never be sent.
399+
const { policy } = bindEncryptKey({ default: { type: 'encrypt' }, perEntity: {} }, null);
400+
const built = buildAnonymizeRequest('x', [{ entity_type: 'IN_PAN', start: 0, end: 1, score: 1 }], policy);
401+
assert.notDeepEqual(built.anonymizers.DEFAULT, { type: 'encrypt', key: '' });
402+
assert.deepEqual(built.anonymizers.DEFAULT, { type: 'replace' });
403+
});

0 commit comments

Comments
 (0)