--- title: "GDPR Field Encryption" description: "How every SDK encrypts the PII leaves of a message's data in place (ADR-0030): the validation-neutral x-gdpr-sensitive schema keyword declares which fields carry personal data; a caller-bound Cipher seam (so the core pulls no crypto dependency) plus opt-in protect / unprotect helpers walk the schema's sensitive paths and rewrite only the marked values, leaving data pure JSON and the envelope frozen. The registry's bqschema gdpr declares and audits the same keyword. Validate cleartext, before protect and after unprotect. The wire envelope stays frozen at schema_version 1." source: https://babelqueue.com/docs/spec/1.x/gdpr-field-encryption/ updated: 2026-06-21T00:00:00.000Z --- # GDPR Field Encryption A message's [`data`](/docs/spec/1.x/envelope/) is, by contract, **pure JSON the caller owns**. When it carries personal data — an email, a national id, a postal address — GDPR/KVKK ask you to encrypt those fields at rest and in transit. BabelQueue does this **per field**, not per envelope: the SDK encrypts exactly the leaves you mark as sensitive, in place, leaving the rest of `data` (and the whole envelope frame) untouched (ADR-0030). > **Status:** Authoritative · Optional helper · envelope frozen at `schema_version: 1` This is purely additive and validation-neutral. A protected value is still a JSON **string** (the ciphertext), so `data` stays pure JSON (GR-3), the envelope is unchanged (`schema_version: 1`, `trace_id` preserved — GR-1/GR-4), and an SDK without the key can still carry the message; it just can't read the protected fields. The crypto is **opt-in** and lives in a `gdpr` module the core never imports by default. ## Two halves: declare/audit, and enforce GDPR support is split exactly along the line between the registry and the SDKs: - **Declare + audit (the [registry](/docs/spec/1.x/schema-validation/), `bqschema gdpr`).** An `x-gdpr-sensitive` JSON-Schema keyword marks which `data` fields are PII. The registry inventories them, **gates CI** when a PII-named field is left un-annotated, and **masks** a message for safe logging. This is governance — it never sees runtime traffic. - **Enforce at runtime (the SDKs).** Each SDK reads the **same** `x-gdpr-sensitive` annotation off the per-URN schema and encrypts/decrypts those leaves when it produces and consumes. This page is that runtime half. The registry's `gdpr --mask` is the **one-way, registry-side logging** equivalent — it redacts a value for a log line; it is explicitly **not** a crypto primitive and does not protect data on the wire. That is the SDK's job. ## The `x-gdpr-sensitive` keyword A property-level JSON-Schema extension on the per-URN `data` schema, accepting either `true` or a non-empty **string category** (`"email"`, `"national_id"`, …): ```json { "type": "object", "properties": { "order_id": { "type": "integer" }, "email": { "type": "string", "x-gdpr-sensitive": "email" }, "profile": { "type": "object", "properties": { "full_name": { "type": "string", "x-gdpr-sensitive": true } } }, "addresses": { "type": "array", "items": { "type": "object", "properties": { "line": { "type": "string", "x-gdpr-sensitive": true } } } } } } ``` It is **validation-neutral**: the keyword is *parsed* but **ignored by validation and by `compat`**, so annotating an existing schema is **never** a breaking change that forces a new URN. It nests — into nested objects (`profile.full_name`) and array items (`addresses[].line`) — and the schema model exposes those marked leaves as **sensitive paths**, the same walk the registry's audit uses. ## The `Cipher` seam The actual encryption is a **caller-provided** interface bound to your KMS / Vault / HSM / tokenisation service — so the **core pulls no crypto dependency** (GR-7): | Method | Meaning | | :--- | :--- | | `encrypt(plaintext) → string` | Encrypt a value's bytes to a ciphertext **string** (so `data` stays JSON). | | `decrypt(ciphertext) → bytes` | The inverse; a wrong-key/tampered input raises a **typed** decrypt error. | Each SDK also ships a **reference cipher** for callers who hold their own AES-256-GCM key (random nonce prepended, base64). It does **no key management** — it is the simplest correct implementation, not a KMS. The reference is built on each platform's in-box crypto: | SDK | Reference cipher | Built on | | :--- | :--- | :--- | | Go | `AESGCMCipher` (`NewAESGCMCipher(key)`) | stdlib `crypto/aes` + `crypto/cipher` | | PHP | `OpenSslCipher` | `ext-openssl` (a `suggest`, not a `require`) | | Node | `AesGcmCipher` | built-in `node:crypto` | | Java | `AesGcmCipher` | JDK `javax.crypto` | | .NET | `AesGcmCipher` | in-box `System.Security.Cryptography.AesGcm` | | Python | *(none in core)* | the AES-256-GCM reference is the optional `babelqueue[gdpr]` extra (`cryptography`) | Python is the one exception: its stdlib has no AES-GCM, so the core ships only the `Cipher` protocol and the reference cipher rides the optional `[gdpr]` extra — never a core dependency. ## `protect` / `unprotect` Two standalone, opt-in helpers that walk the schema's sensitive paths and rewrite **only the marked values** in `data`, in place: - **`protect(data, schema, cipher)`** — producer-side, **after** you build `data` and **before** you encode. Each marked leaf's value is canonically JSON-encoded, then replaced by the cipher's ciphertext string. An absent marked field is **skipped** (not an error). - **`unprotect(data, schema, cipher)`** — consumer-side, **after** decode and **before** handling. The byte-for-byte inverse — a wrong-key decrypt returns a **typed error**, so the message takes the retry / [dead-letter](/docs/spec/1.x/dead-letter-and-tracing/) path instead of being silently mishandled. The round-trip is **exact**: because the value is canonically JSON-encoded before encryption, `unprotect(protect(data)) == data`. Only values inside `data` change — no envelope field is added, renamed or removed. ### Validate cleartext, not ciphertext A schema that constrains a sensitive field (e.g. `"format": "email"`) would **reject the ciphertext string**. So order the steps around the plaintext: - **Producer:** [validate](/docs/spec/1.x/schema-validation/) → **`protect`** → encode → publish. - **Consumer:** decode → **`unprotect`** → validate → handle. Validation always sees cleartext; the wire always carries ciphertext. ## Go (the reference) The `…/gdpr` subpackage of the core module (stdlib only). `Protect`/`Unprotect` are free functions over the schema's `SensitivePaths()`: ```go import ( "github.com/babelqueue/babelqueue-go/gdpr" "github.com/babelqueue/babelqueue-go/schema" ) cipher, _ := gdpr.NewAESGCMCipher(key) // your own 32-byte key; or bind a KMS to gdpr.Cipher sch, _ := schema.Load("urn:babel:orders:created") // producer: encrypt the marked leaves in place, before encode if err := gdpr.Protect(data, sch, cipher); err != nil { /* … */ } // consumer: the byte-for-byte inverse, after decode if err := gdpr.Unprotect(data, sch, cipher); err != nil { // errors.Is(err, gdpr.ErrDecrypt) → wrong key/tampered → retry / dead-letter } ``` `Cipher` is the interface (`Encrypt(plaintext []byte) (string, error)` / `Decrypt(ciphertext string) ([]byte, error)`); bind it to a KMS/Vault and the core stays crypto-free. ## PHP `BabelQueue\Gdpr\Gdpr::protect()` / `::unprotect()` are static helpers; `OpenSslCipher` is the reference over `ext-openssl`: ```php use BabelQueue\Gdpr\Gdpr; use BabelQueue\Gdpr\OpenSslCipher; $cipher = new OpenSslCipher($key); // or implement BabelQueue\Gdpr\Cipher over a KMS // producer: encrypt the marked leaves in place (validate first) Gdpr::protect($data, $schema, $cipher); // consumer: the inverse (then validate, then handle) try { Gdpr::unprotect($data, $schema, $cipher); } catch (\BabelQueue\Gdpr\DecryptException $e) { // wrong key / tampered → retry / dead-letter } ``` `ext-openssl` is a Composer `suggest`, not a `require`, so the core stays `ext-json`-only. ## Per-SDK GDPR field encryption shipped across all six SDK cores with the same contract — a caller-bound `Cipher` seam, opt-in `protect`/`unprotect` over the schema's sensitive paths, a byte-for-byte round-trip, a typed wrong-key error feeding retry/DLQ, and the envelope frame never touched. Each shipped as a per-SDK MINOR with the envelope frozen. | SDK | Cipher interface | `protect` / `unprotect` | Sensitive paths | | :--- | :--- | :--- | :--- | | Go | `gdpr.Cipher` | `gdpr.Protect` / `gdpr.Unprotect` (free funcs) | `schema.Schema.SensitivePaths()` | | PHP | `BabelQueue\Gdpr\Cipher` | `Gdpr::protect` / `Gdpr::unprotect` (static) | `BabelQueue\Schema\SensitivePaths::of()` | | Node | `Cipher` (`@babelqueue/core`) | `protect` / `unprotect` (functions) | `sensitivePaths(schema)` | | Java | `com.babelqueue.gdpr.Cipher` | `Gdpr.protect` / `Gdpr.unprotect` (static) | `SensitivePaths.of(schema)` | | .NET | `BabelQueue.Gdpr.ICipher` | `Gdpr.Protect` / `Gdpr.Unprotect` (static) | `SchemaSensitivity.SensitivePaths(schema)` | | Python | `Cipher` protocol (`babelqueue.gdpr`) | `protect` / `unprotect` (functions) | `sensitive_paths(schema)` | Idiomatic differences to expect: **.NET** names the interface `ICipher`; **PHP** and **Java** and **.NET** invoke through a static `Gdpr` class while **Go**, **Node** and **Python** use free functions; the typed wrong-key error is `ErrDecrypt` (Go), `DecryptException` (PHP/Java), `DecryptError` (Node/Python), `ProtectedFieldException` (.NET). Only **Python** ships no concrete cipher in core — the AES-256-GCM reference is the `[gdpr]` extra. See each SDK's reference for binding a cipher: [Go](/docs/babelqueue-go/1.x/introduction/), [PHP](/docs/php-sdk/1.x/introduction/), [Python](/docs/babelqueue-python/1.x/introduction/), [Node](/docs/babelqueue-node/1.x/introduction/), [Java](/docs/babelqueue-java/1.x/introduction/), [.NET](/docs/babelqueue-dotnet/1.x/introduction/). For the registry side — declaring the keyword, the `bqschema gdpr` audit gate, masking for logs, and AsyncAPI carry-through — see [Per-URN schema validation](/docs/spec/1.x/schema-validation/). Continue to [DLQ redrive & replay-bypass](/docs/spec/1.x/redrive-and-replay/).