GDPR Field Encryption
A message’s data 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,
bqschema gdpr). Anx-gdpr-sensitiveJSON-Schema keyword marks whichdatafields 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-sensitiveannotation 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", …):
{
"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 builddataand 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 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 →
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():
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:
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, PHP, Python, Node, Java, .NET.
For the registry side — declaring the keyword, the bqschema gdpr audit gate, masking for
logs, and AsyncAPI carry-through — see Per-URN schema validation.
Continue to DLQ redrive & replay-bypass.