Documentation
¶
Overview ¶
Package gdpr is the RUNTIME half of ADR-0030: SDK-side field-level encryption of the data fields a registry declared x-gdpr-sensitive. The babelqueue-registry only DECLARES and AUDITS sensitivity (and offers one-way Mask for safe logging); this package ENFORCES it on the wire — a producer encrypts each marked leaf before publish, a consumer decrypts it after decode.
It is the Go reference other SDKs mirror, so the contract is deliberately tight:
- The envelope stays FROZEN (GR-1). Protect mutates only VALUES inside data: a sensitive leaf's value becomes a ciphertext STRING. It never adds, renames, removes or retypes an envelope field; meta.schema_version stays 1; trace_id is untouched (GR-4). data remains pure JSON (GR-3) — a JSON string is still pure JSON, so any SDK can carry the envelope even without the key (it just can't read the protected fields). The which-fields-are-sensitive fact lives in the schema, not the message, so nothing about the frame changes.
- Zero heavy dependencies (GR-7). The crypto is a caller-provided Cipher interface (KMS/Vault/HSM/tokenisation); the bundled AESGCMCipher is stdlib-only. The core module pulls no crypto/KMS dependency.
The sensitive paths come from the SAME per-URN schema.Schema the produce/consume validation path already loads (via a schema.Provider) — the x-gdpr-sensitive marks ride on it (schema.Schema.SensitivePaths). Protect/Unprotect are standalone helpers the caller invokes, so it is strictly opt-in: a producer/consumer that never calls them behaves exactly as before.
Typical wiring (producer):
env, _ := babelqueue.Make(urn, data, babelqueue.WithQueue("orders"))
sch, ok, _ := provider.Schema(env.URN())
if ok {
_ = schema.Check(provider, env.URN(), env.Data) // optional: validate cleartext first
_ = gdpr.Protect(env.Data, sch, cipher) // encrypt marked leaves IN PLACE
}
body, _ := env.Encode() // ciphertext rides inside data
and the inverse on the consumer, after Decode and before the handler reads env.Data:
in, _ := babelqueue.Decode(body)
sch, ok, _ := provider.Schema(in.URN())
if ok {
_ = gdpr.Unprotect(in.Data, sch, cipher) // decrypt marked leaves IN PLACE
}
Validate cleartext BEFORE Protect / AFTER Unprotect — a schema that constrains a sensitive field (minLength, enum, …) would reject the ciphertext string otherwise.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrDecrypt = errors.New("babelqueue/gdpr: cannot decrypt a protected field")
ErrDecrypt wraps any failure to restore a protected field on the consume side (a wrong key, a tampered/garbled ciphertext, or a value that is not the string Protect produced). Unprotect stops at the first such failure and returns it wrapped so it is distinguishable from a missing field (which is skipped, not an error). Detect it with errors.Is.
var ErrInvalidKeySize = errors.New("babelqueue/gdpr: AES key must be 16, 24, or 32 bytes")
ErrInvalidKeySize is returned by NewAESGCMCipher when the key is not 16, 24, or 32 bytes (AES-128/192/256). Detect it with errors.Is.
var ErrMalformedCiphertext = errors.New("babelqueue/gdpr: malformed ciphertext")
ErrMalformedCiphertext is returned by AESGCMCipher.Decrypt when the input is not valid base64 or is too short to contain a nonce — i.e. not something this cipher produced. Detect it with errors.Is.
Functions ¶
func Protect ¶
Protect encrypts, in place, every value in data located at a path the schema marked x-gdpr-sensitive — the producer-side step, run after building data and before Encode/publish. Each marked leaf's value is canonically JSON-encoded and replaced by cipher.Encrypt's ciphertext STRING; the envelope frame, non-sensitive fields, and key order are untouched (GR-1).
A marked path that is absent from data is skipped (not an error) — schemas evolve and a message need not carry every optional field. data may be nil (no-op). A nil schema or one with no marks is a no-op (nothing is sensitive). Container marks (a whole object/array marked sensitive) are supported: the entire sub-value is encoded and encrypted as one ciphertext string.
On any cipher error Protect returns it wrapped and leaves data in a partially-protected state; callers should treat a Protect error as fatal for that message (do not publish it).
func Unprotect ¶
Unprotect is the consumer-side inverse of Protect: it decrypts, in place, every value in data at an x-gdpr-sensitive path, restoring the original JSON value byte-for-byte. Run it after Decode and before the handler reads data.
An absent path is skipped. A leaf that is NOT a string — e.g. it was never protected, or this is a re-run after a successful Unprotect — is left as-is, so re-invoking Unprotect on already- cleartext data is safe (idempotent for non-string leaves). A string that the cipher cannot open (wrong key, tampered, or not a ciphertext) returns ErrDecrypt, wrapped — the consumer should fail the message (retry / dead-letter) rather than process unreadable PII.
Types ¶
type AESGCMCipher ¶
type AESGCMCipher struct {
// contains filtered or unexported fields
}
AESGCMCipher is a reference Cipher built ONLY on the Go standard library (crypto/aes + crypto/cipher + crypto/rand): AES-GCM authenticated encryption with a fresh random nonce per call, the nonce prepended to the ciphertext, the whole thing base64 (std, padded) encoded so it drops straight into a JSON string. The key is the CALLER's — this type performs no key management, rotation or derivation; bind a KMS-backed Cipher for that.
AES-256-GCM is used when the key is 32 bytes (the recommended size); 16- and 24-byte keys select AES-128/192-GCM. GCM authenticates the ciphertext, so Decrypt rejects any tampered or wrong-key input with an error (it does not return corrupt plaintext). It is zero-dependency and safe for concurrent use (the cipher.AEAD is created once and only read).
func NewAESGCMCipher ¶
func NewAESGCMCipher(key []byte) (*AESGCMCipher, error)
NewAESGCMCipher builds an AES-GCM reference cipher from a raw symmetric key. The key length selects the AES variant: 32 bytes → AES-256-GCM (recommended), 24 → AES-192, 16 → AES-128. Any other length returns ErrInvalidKeySize.
func (*AESGCMCipher) Decrypt ¶
func (c *AESGCMCipher) Decrypt(ciphertext string) ([]byte, error)
Decrypt reverses Encrypt: it base64-decodes, splits off the prepended nonce, and opens the GCM ciphertext. A wrong key or tampered input fails GCM authentication and returns an error (never corrupt plaintext). Implements Cipher.
type Cipher ¶
type Cipher interface {
// Encrypt protects one field value (its canonical JSON bytes) and returns a JSON-safe
// ciphertext string.
Encrypt(plaintext []byte) (string, error)
// Decrypt reverses Encrypt, returning the original field-value JSON bytes.
Decrypt(ciphertext string) ([]byte, error)
}
Cipher is the field-level protection primitive that the CALLER provides — a seam onto a KMS, Vault transit, an HSM, a tokenisation service, or the reference AESGCMCipher below. Protect runs Encrypt over every x-gdpr-sensitive leaf's value (after it is canonically JSON-encoded); Unprotect runs Decrypt to restore it. Keeping this an interface is what holds GR-7: the core module never pulls a crypto/KMS dependency — only a caller who imports a concrete backend does.
Contract for an implementation:
- Encrypt takes the canonical JSON bytes of one field value (see Protect) and returns the ciphertext as a STRING — it MUST be valid for placement inside a JSON document (the AESGCMCipher reference returns base64, which is). The same plaintext MAY encrypt to different strings each call (a random nonce/IV is expected and good).
- Decrypt is the exact inverse: given a string Encrypt produced, it returns the original JSON bytes byte-for-byte. A string it did not produce, or one produced under a different key, MUST return an error rather than silent garbage, so a wrong-key consume fails loudly.
- Both MUST be safe for concurrent use; a producer/consumer fans the same Cipher across goroutines.