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). 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", …):

{
  "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 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: validateprotect → 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.