Skip to content

Commit 5968b96

Browse files
committed
feat(gdpr): runtime field encryption for x-gdpr-sensitive — Node core (ADR-0030)
The SDK-enforcement half of GDPR governance (mirrors the Go reference). New gdpr module: a caller-bound Cipher interface (sync encrypt/decrypt onto KMS/Vault) + a reference AesGcmCipher on node:crypto (AES-GCM, base64(iv||ct||tag)), and protect/unprotect helpers that encrypt/decrypt exactly the schema's x-gdpr-sensitive leaves in place (nested + array + root), byte-for-byte round-trip, wrong key -> typed DecryptError. schema.sensitivePaths() parses x-gdpr-sensitive (validation-neutral). data stays pure JSON (ciphertext string) so the envelope is frozen (GR-1, schema_version 1, trace_id preserved); the Cipher seam keeps the core zero-dep (GR-7). Opt-in; schema validation on cleartext. v1.7.0.
1 parent 75e1358 commit 5968b96

8 files changed

Lines changed: 853 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,43 @@ The envelope wire format is versioned separately by `meta.schema_version`
99

1010
## [Unreleased]
1111

12+
## [1.7.0] - 2026-06-21
13+
14+
### Added
15+
- **Runtime GDPR field encryption — the SDK-enforcement half of `x-gdpr-sensitive`
16+
(ADR-0030).** The registry only *declares* and *audits* which `data` fields are
17+
personal/sensitive; this core now *enforces* it on the wire. A producer encrypts each
18+
marked leaf before publish, a consumer decrypts it after decode — PII rides the wire as
19+
ciphertext while the envelope stays frozen. The Node mirror of the Go reference; purely
20+
additive, opt-in and validation-neutral.
21+
- New `gdpr.protect(data, schema, cipher)` / `gdpr.unprotect(...)` standalone helpers —
22+
they rewrite each `x-gdpr-sensitive` leaf **in place**: `protect` canonically
23+
JSON-encodes the value then replaces it with the cipher's **ciphertext string**;
24+
`unprotect` is the byte-for-byte inverse (numbers restore to numbers, objects to
25+
objects). They walk **nested objects** (`profile.full_name`) and **array items**
26+
(`addresses[].line`); an absent marked field is skipped (not an error); a non-sensitive
27+
field is never touched. A wrong-key / tampered / non-ciphertext value throws the typed
28+
`DecryptError` so the consumer fails the message (retry / dead-letter).
29+
- New `Cipher` interface — `encrypt(bytes): string` / `decrypt(string): bytes`, both
30+
**synchronous** — the caller-provided seam onto a KMS / Vault / HSM / tokenisation
31+
service, so the core pulls **no** crypto dependency (GR-7).
32+
- New `AesGcmCipher` reference cipher built **only** on `node:crypto` — AES-GCM with a
33+
random 12-byte IV prepended and base64 output (`base64(iv || ciphertext || tag)`);
34+
16/24/32-byte keys select AES-128/192/256-GCM. The caller owns the key (no key
35+
management). GCM authenticates, so a wrong key or tampered input throws rather than
36+
returning corrupt plaintext. New typed errors `DecryptError`, `InvalidKeySizeError`,
37+
`MalformedCiphertextError`.
38+
- New `schema.sensitivePaths(schema)` + `SensitivePath` type — parses the
39+
`x-gdpr-sensitive` keyword (boolean `true` or non-empty string category) and walks
40+
nested objects, array items and the root mark, in sorted path order. Parsing is
41+
**validation-neutral** — the keyword never makes a value valid or invalid, so annotating
42+
a schema is non-breaking.
43+
- The envelope stays **frozen** (GR-1): only `data` *values* change, a ciphertext is a
44+
JSON string so `data` stays pure JSON (GR-3), `meta.schema_version` stays **1** and
45+
`trace_id` is preserved (GR-4). An SDK without the key still carries the envelope.
46+
Validate **cleartext** — before `protect`, after `unprotect`. Entirely opt-in and
47+
backward compatible.
48+
1249
## [1.6.0] - 2026-06-21
1350

1451
### Added

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,52 @@ Implement `OutboxStore` over your DB (`save`, `fetchUnpublished` oldest-first
165165
adapter SHOULD claim/lock rows so two relays don't double-publish — `markPublished`,
166166
`markFailed`) and `OutboxTransport` (`publish(body, queue)`) over your broker.
167167

168+
### GDPR field encryption (optional)
169+
170+
A schema can mark a `data` field `x-gdpr-sensitive` (ADR-0030). `gdpr.protect` encrypts
171+
each marked leaf **in place** before publish, and `gdpr.unprotect` restores it after
172+
decode — so PII rides the wire as ciphertext while the envelope stays frozen.
173+
174+
```ts
175+
import { EnvelopeCodec, gdpr, schema } from "@babelqueue/core";
176+
177+
// Your key, your cipher — bind a KMS/Vault/HSM here, or use the bundled AES-256-GCM one.
178+
const cipher = new gdpr.AesGcmCipher(myKey); // 16/24/32-byte key
179+
180+
// PRODUCER — validate CLEARTEXT, then encrypt the marked leaves.
181+
const env = EnvelopeCodec.make("urn:babel:people:created", person, { queue: "people" });
182+
const s = provider.schemaFor(env.job);
183+
if (s) {
184+
await schema.validate(provider, env.job, env.data); // cleartext, before protect
185+
gdpr.protect(env.data, s, cipher); // email/full_name/... → ciphertext
186+
}
187+
const body = EnvelopeCodec.encode(env); // ciphertext rides inside data
188+
189+
// CONSUMER — decrypt the marked leaves, then validate CLEARTEXT.
190+
const incoming = EnvelopeCodec.decode(body);
191+
const cs = provider.schemaFor(incoming.job!);
192+
if (cs) {
193+
gdpr.unprotect(incoming.data as Record<string, unknown>, cs, cipher);
194+
await schema.validate(provider, incoming.job!, incoming.data as Record<string, unknown>);
195+
}
196+
```
197+
198+
`protect` canonically JSON-encodes each marked value then replaces it with the cipher's
199+
**ciphertext string**, so the round-trip is byte-for-byte exact (numbers come back as
200+
numbers, objects as objects). It walks **nested objects** (`profile.full_name`) and
201+
**array items** (`addresses[].line`); an absent marked field is skipped; a non-sensitive
202+
field is never touched. Validate **cleartext** — before `protect`, after `unprotect`
203+
because a schema constraining a sensitive field would reject the ciphertext string.
204+
205+
The `Cipher` is an **interface you bind** (`encrypt(bytes) → string` /
206+
`decrypt(string) → bytes`, both synchronous) so the core pulls **no crypto dependency**
207+
(GR-7). The bundled `AesGcmCipher` is built only on `node:crypto` (AES-256-GCM, random IV
208+
prepended, base64; GCM auth-tag verified so a wrong key or tampered ciphertext **throws**
209+
`DecryptError`). The envelope stays **frozen** (GR-1): only `data` *values* change, a
210+
ciphertext is a JSON string so `data` stays pure JSON (GR-3), `meta.schema_version` stays
211+
**1** and `trace_id` is preserved (GR-4) — an SDK without the key still carries the
212+
envelope, it just can't read the protected fields. Entirely opt-in.
213+
168214
## What this core is (and isn't)
169215

170216
It enforces the **contract**: the envelope shape, URN identity, trace propagation,

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@babelqueue/core",
3-
"version": "1.6.0",
3+
"version": "1.7.0",
44
"description": "Polyglot Queues, Simplified — the Node/TypeScript core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.",
55
"keywords": [
66
"queue",
@@ -59,7 +59,7 @@
5959
"build": "tsup",
6060
"typecheck": "tsc --noEmit",
6161
"lint": "eslint src test",
62-
"test": "node --import tsx --test test/codec.test.ts test/dead-letter.test.ts test/conformance.test.ts test/overhead.test.ts test/idempotency.test.ts test/schema.test.ts test/otel.test.ts test/redrive.test.ts test/replay.test.ts test/outbox.test.ts",
62+
"test": "node --import tsx --test test/codec.test.ts test/dead-letter.test.ts test/conformance.test.ts test/overhead.test.ts test/idempotency.test.ts test/schema.test.ts test/otel.test.ts test/redrive.test.ts test/replay.test.ts test/outbox.test.ts test/gdpr.test.ts",
6363
"coverage": "c8 --check-coverage --lines 90 --functions 90 --branches 85 --reporter=text npm test",
6464
"prepublishOnly": "npm run build"
6565
},

0 commit comments

Comments
 (0)