Spec: Field-Level Encryption — Classification, XChaCha20-Poly1305 & Rotation¶
File: .ai/specs/app-layer/2026-07-02-field-encryption.md Status: Approved (security review complete 2026-07-04 — conditions incorporated in v0.2; see Changelog) Owners: App-layer lane; reviewers: storage lane (opacity contract), security review required Related: PR-043; F2 ratified; vector-storage spec (exclusion); grpc-contracts spec (token key rotation OQ folded here)
1. Overview¶
Defines what gets encrypted (the classification policy structure), how (XChaCha20-Poly1305, envelope format, key hierarchy), the rotation procedure, and the opacity contract with Telha Core — including the deliberate consequence that classified fields are invisible to indexing and vector search.
2. Problem Statement¶
v7 said "AES-GCM encrypts sensitive fields" and stopped. Unanswered: nonce management at scale (GCM's 96-bit random nonces demand rotation discipline), which fields count as sensitive (tribal knowledge is a breach postmortem waiting to happen), how keys rotate without rewriting history, and how the system guarantees ciphertext never leaks into embeddings or indexes.
3. Proposed Solution¶
Ratified F2: XChaCha20-Poly1305 with random 192-bit nonces — collision probability negligible at any realistic volume, removing rotation-as-nonce-hygiene pressure (rotation remains for compromise response and policy). Deviation from v7's AES-GCM wording recorded here (see §9). Classification is declarative: each module ships classification.ts mapping entity/label → classified field lists with a reason tag (pii | financial | secret); the field-crypto service refuses to persist a record whose label has no classification entry (absence must be an explicit classified: [], forcing the decision). Key hierarchy: per-tenant DEKs, wrapped by a KMS-held KEK; DEKs cached decrypted in-memory with TTL. Cache hygiene (security conditions): cached DEKs live in zeroize-on-drop wrappers, the cache is bounded, and rotate(tenant) invalidates the tenant's cache entry immediately — a compromised-then-rotated key must not keep serving from cache until TTL. Nonces come exclusively from the platform CSPRNG (libsodium randombytes_buf); no counter or derived-nonce scheme may be substituted.
4. Architecture¶
module write ─▶ field-crypto.encrypt(label, record) ─▶ classified fields → envelopes ─▶ @telha/sdk ─▶ core (opaque)
module read ─▶ core ─▶ field-crypto.decrypt (envelope-detected fields) ─▶ plaintext in app layer only
rotation job (BullMQ) ─▶ new DEK ─▶ re-encrypt-on-write-forward: new Telha versions under new key_id (append-only compatible)
5. Data Models¶
Envelope (bytes, msgpack): {v:1, alg:"xchacha20poly1305", key_id, nonce: 24B, ct, tag: 16B} prefixed with magic 0x7E 0x1A — the magic is what vector-storage's exclusion function and opacity tests detect. AAD binds context: tenant_id ‖ label ‖ logical_id ‖ field_name — the review added logical_id (security condition): without record identity in the AAD, an envelope can be transplanted between two records of the same tenant/label/field (Alice's salary pasted into Bob's record authenticates fine — the classic cut-and-paste gap AAD exists to close). Consequence: classified-label creates must know the logical id at encrypt time, so the app layer generates UUIDv7 logical ids client-side for classified labels and supplies them on create; if the create path does not yet accept client-supplied ids, PR-043 adds it (recorded as a scoped API addition, classified labels only). An envelope moved between fields, labels, records, or tenants fails authentication. DEK record (PostgreSQL, enterprise schema): {key_id, tenant, wrapped_dek, created, state: active|retiring|destroyed}.
6. API Contracts¶
encrypt(scope, label, record) -> record / decrypt(scope, label, record) -> record (envelope auto-detection on read; unknown key_id → typed KeyUnavailable, never silent plaintext-passthrough). Security conditions: (a) decrypt verifies the DEK record's tenant matches the caller's scope BEFORE any unwrap/crypto — cross-tenant key_id references fail early with KeyUnavailable (the tenant-bearing AAD would also fail, but failing pre-crypto avoids an authentication oracle); (b) fail closed — if the KEK/KMS is unreachable, classified-label writes and reads error (KeyUnavailable); there is no plaintext fallback in either direction; (c) no side door — the telha module's write path refuses raw (non-field-crypto) writes to any label present in the classification registry, so a module cannot bypass encryption by calling the SDK directly (integration test required); (d) the field-crypto service never logs plaintext or envelope bytes; error context is limited to label, field name, and key_id. Rotation: rotate(tenant) creates new active DEK, marks prior retiring; retiring keys decrypt-only; destruction requires zero remaining references (scan) or an explicit crypto-shred order (gdpr-erasure spec). Telha opacity contract (tested from the core side): stored bytes for classified fields always begin with envelope magic; no plaintext classified value ever crosses gRPC.
7. UI/UX¶
Admin: key inventory view (enterprise package) — key states, ages, reference counts. Developer: classification.ts template in module scaffolding next to ROUTING.md (data-split-routing spec).
8. Configuration¶
KMS provider ref (KEK), crypto.dek_cache_ttl (300s), rotation schedule (default 180d policy reminder, not forced). Cipher and envelope format are NOT configurable — agility is v2 of the envelope, not a knob.
9. Alternatives Considered¶
AES-256-GCM (v7's wording; rejected per ratified F2: 96-bit nonces at high volume require birthday-bound rotation discipline; XChaCha's 192-bit nonce space removes the failure mode — this section is the recorded deviation). Deterministic/searchable encryption (rejected: leaks equality patterns; the product answer is "classified fields are not searchable," stated honestly). Per-field keys (rejected: key explosion; AAD binding gives per-field integrity at per-tenant key cost). Envelope-less ciphertext columns (rejected: self-describing envelopes enable auto-detection, mixed-version reads, and the opacity test).
10. Implementation Approach¶
PR-043 commits as planned: this spec → field-crypto service (libsodium binding) → telha-module integration → rotation job → opacity + rotation + nonce-uniqueness statistical tests → indexes/vectors exclusion verification (with vector-storage spec's adversarial test).
11. Migration Path¶
Greenfield. Envelope v2 (cipher agility) would ship dual-read/single-write like value-version bytes. Re-encryption is always forward (new Telha versions); historical ciphertext under retiring keys remains readable until an explicit destruction event.
12. Success Metrics¶
Opacity test green (raw byte inspection, core side). AAD test: envelope transplanted across field/label fails auth. Rotation round-trip: old versions readable, new writes under new key_id. Nonce-uniqueness statistical test over 10M generations. Classification-absence test: unclassified label write refused.
13. Open Questions¶
- OQ-1: Resolved at security review (2026-07-04) — fold in, with a boundary: token signing keys join the same key inventory and rotation story (visible in the admin view, same rotation runbook and audit trail) as a distinct non-tenant key class, but token verification does NOT route through the KMS — token keys are hot-path symmetric HMAC keys verified on every request; coupling them to KMS availability/latency would make KMS an availability dependency of all auth. They keep the existing in-process keyset rotation mechanism (PR-026); the inventory tracks them, the KMS does not hold them.
14. Changelog¶
- 2026-07-04 — v0.3: Built (PR-043) — as-built record. Implementation:
@telha/enterprisesrc/field-crypto/(envelope/cipher/classification/keys/service/transport/rotation) +@telha/contractsclassification.ts; all seven security conditions implemented and tested (AAD transplant matrix, cache hygiene, pre-crypto tenant scoping, fail-closed KMS both directions, no-side-door, log hygiene by construction — error types carry only label/field/key_id). Spec-mandated 10M-nonce statistical run executed 2026-07-04. As-built decisions and additions: - Core wire additions (recorded normatively in grpc-contracts v0.4): client-supplied
idon record creates (nil → invalid, any existing version → conflict) — the §5 condition; and a full-snapshot record-update surface (PUT /v1/records/:id,UpdateRecordRPC, SDKrecords.update) — §4's re-encrypt-forward had no way to write new versions of existing records. {"$bytes": base64}write convention on the records/relationships DTOs (top level of a property, write surface only): plain JSON cannot expressValue::Bytes, and the opacity contract requires stored classified bytes to BE the magic-prefixed envelope, not base64 text of it. Reads renderBytesas base64 strings (existing PR-024 rule); envelope auto-detection accepts both shapes.- AAD label component = lexicographically smallest label on the record (not "the classifying label"): re-derivable on read regardless of later registry changes; envelopes still fail auth when moved across label sets. Records are single-labeled in practice.
- Integration is transport-level:
TelhaConnectorgainedtransportWrap;registerFieldCryptoModulere-registers TELHA so every session client encrypts classified fields on write and decrypts envelopes on read. This SUBSUMES the §6(c) refusal — plaintext cannot cross the sanctioned path at all; labels with no classification entry are still refused outright (§3 rule). - KMS binding is an interface (
KmsProvider); v1 shipsEnvKekProvider(32-byte KEK fromFIELD_CRYPTO_KEK, DEK-wrap = XChaCha20-Poly1305 with tenant-id AAD). Pointing at a real KMS is deployment configuration, not a code change. - DEK cache owns defensive copies both directions and zeroes only its own buffers — the naive shared-buffer zeroize corrupted in-flight encryptions under concurrency (caught by test). First-DEK provisioning is serialized per tenant in-process; the PG store should add a partial unique index on
(tenant_id) WHERE state='active'(noted in code). - Rotation: BullMQ queue
field-crypto-rotation; sweep = raw read (see key_ids) → local decrypt → update through the encrypting path; reportsremainingOldKeyRefsas the §6 zero-reference destruction gate;destroy(keyId, order)implements the explicit crypto-shred order. Declassified-since-write fields exit a sweep as plaintext (intended declassification path). - Nonce statistical test checks 64-bit-prefix uniqueness (strictly implied by any full collision; spurious-failure odds ≈ N²/2^65) plus a byte-uniformity band; env-scaled, default 200k, spec run
TELHA_NONCE_TEST_N=10000000. - 2026-07-04 — v0.2: Security review complete — Approved with conditions, all incorporated (reviewer: Claude, acting security reviewer by J. Porter's delegation; jp@densops.com to countersign or veto). Conditions: (1) AAD extended with logical_id — field/label/tenant binding alone permits same-column envelope transplant between records; classified-label creates use app-generated UUIDv7 ids; (2) DEK cache: zeroize-on-drop, bounded, invalidated immediately on rotation; (3) nonce source pinned to libsodium CSPRNG; (4) decrypt checks DEK tenant scope pre-crypto; (5) fail closed on KMS unavailability, both directions; (6) no-side-door guard — telha module refuses raw writes to classified labels (integration-tested); (7) no plaintext/envelope bytes in logs. OQ-1 resolved (token keys: same inventory, never KMS-held). Cipher choice, envelope format, non-searchability doctrine, and retiring/destruction semantics approved as drafted.
- 2026-07-02 — v0.1: initial draft for peer review (records the ratified AES-GCM → XChaCha20-Poly1305 deviation).