Documentation

Cryptography

The flare-encrypt three-tier AES-256-GCM envelope: per-environment KEK, per-tenant DEK, ciphertext; the frozen encryption context with golden vectors; version axes; GCM hardening; rotation; honest crypto-shred; and the error taxonomy.

The three-tier envelope

flare-encrypt encrypts each value under a three-tier envelope: a per-environment KEK(wrapping key) wraps a per-tenant DEK (data key), which encrypts each ciphertext. Confidentiality comes from the keys. Placement integrity — the fact that a ciphertext cannot be relocated to another tenant or column — comes from a mandatory AES-GCM encryption context (the additionalData, or AAD) bound to every operation. Everything runs on Web Crypto and is byte-identical under Cloudflare Workers and Node 20+.

TierLivesScopeWrapped byVersioned by
KEKSecrets Store or a Worker Secret — never in D1one per environment— (root of trust)kek_kv (per-environment)
DEKD1, only as AES-GCM(KEK, DEK)one per tenantKEKdek_ver (per-tenant, starts at 1)
ciphertextD1 (ciphertext BLOB)one column of one tenantDEKtagged with dek_ver

The KEK never encrypts application data directly; it only wraps DEKs. That is what makes KEK rotation O(tenants) with zero data re-encryption. Each environment holds its own KEK material: the KEK secret is a two-level map keyed by environment id — { env_id: { kek_kv: bytes } }— so two environments both at kek_kv = 1 hold different bytes. A KEK compromise unwraps only that one environment's tenant DEKs; the blast radius stops at the environment boundary.

Wrapping is AES-GCM encryption of the raw 32-byte DEK under the wrap context — notSubtleCrypto.wrapKey, which cannot attach additionalData and so cannot bind the wrap context. The DEK is treated as 32 opaque bytes, encrypted with the KEK, and stored asct || tag.

The mandatory encryption context

Every encrypt, decrypt, wrapDek, and unwrapDekpasses an AES-GCM encryption context that is authenticated-but-not-encrypted and derived from the row at call time — never stored, never parsed back. Relocating a ciphertext to a different tenant or column, or a wrapped DEK to a different tenant or KEK version, flips a bound field and the GCM tag check fails. There are three context kinds, hard-separated by a kind byte so no two kinds can ever serialize identically:

DATA context     → binds each ciphertext            { aadFmt, tenantId, column, dekVer }
WRAP context     → binds each wrapped DEK           { aadFmt, tenantId, "dek-wrap", kekKv }
KEYWRAP context  → binds each wrapped signing key   { aadFmt, tenantId, keyId, keyVer, kekKv }

The API never accepts a raw context. encrypt/decrypt take a structured context and derive the bytes internally, so a caller physically cannot bind the wrong context by hand.

Identity binding

  • tenantId is the row's immutable primary key (a ULID/UUID string, charset[A-Za-z0-9_-]), copied byte-for-byte from the DB and never re-normalized — no lowercasing, no trimming, no re-encoding. A header-vs-PK divergence changes the bytes and breaks decrypt, so it is an invariant with a test.
  • column is an opaque, fully-qualifying string drawn from a compile-time allowlist; an unknown column raises ContextMismatch before any crypto runs. It is never a human-renamable slug — renaming a secret is a new column, which means re-encrypt.
  • Because tenantId is globally unique and every secret references exactly one tenant, binding { tenantId, column } already prevents cross-tenant and cross-column relocation without project or environment in the context. One schema serves both the library and the store.

Frozen byte layout

The encryption context serializes as a domain-separated, magic-prefixed, big-endian, length-prefixed binary TLV — chosen over JSON/JCS/CBOR precisely because those re-introduce serializer footguns (key ordering, whitespace, unicode escaping, number canonicalization) that a runtime upgrade can silently change. The layout is deterministic by construction and frozen forever; changing it is afmt bump plus a migration, never an in-place edit.

Header (6 bytes, every context):
  magic  4 bytes  "FEAC" = 46 45 41 43
  fmt    1 byte   = aadFmt (the ONLY wire version; sourced from the row's aad_fmt)
  kind   1 byte   = 0x01 DATA | 0x02 WRAP | 0x03 KEYWRAP

Field encodings:
  string field = u16 BE UTF-8 byte length || UTF-8 bytes   (byte length, never JS .length)
  int field    = u32 BE, fixed 4 bytes (no length prefix)

DATA (kind 0x01):    header || LP(tenantId) || LP(column)     || u32(dekVer)
WRAP (kind 0x02):    header || LP(tenantId) || LP("dek-wrap") || u32(kekKv)
KEYWRAP (kind 0x03): header || LP(tenantId) || LP(keyId)      || u32(keyVer) || u32(kekKv)

KEYWRAP (0x03) binds a wrapped signing private key — Ed25519 or ECDSA P-256 (Signing keys). It was added as an additive kind: no existing byte layout changed, the fmt byte is still 1, and the DATA/WRAP golden vectors are untouched — the kind byte is exactly the extension seam that lets the format grow without a migration. Unlike the DEK wrap context, keyVer is bound: a signing-key version record is immutable (only KEK rotation re-wraps it, under a fresh context), so binding the version closes version-slot relocation. The key's alg is deliberately notbound — the PKCS#8 blob names its own algorithm, so a cross-alg import fails structurally.

Each string field's UTF-8 length is capped at 256 bytes (MAX_FIELD_BYTES); the encoder fails closed with ContextMismatch rather than truncating or wrapping. ULIDs are 26 bytes and real column names are far under the cap.

Golden vectors

These bytes are pinned forever. Every repository that encodes a context ships a golden test asserting them, plus a property test (any field change flips the bytes; two distinct contexts never collide) and a CI gate that fails on any layout change without a fmt bump. The committed vectors use generic identifiers only.

encodeDataAad(fmt=1, tenantId="acme", column="c1", dekVer=1)  → 20 bytes
  46 45 41 43 01 01 00 04 61 63 6d 65 00 02 63 31 00 00 00 01

encodeWrapAad(fmt=1, tenantId="acme", kekKv=1)               → 26 bytes
  46 45 41 43 01 02 00 04 61 63 6d 65 00 08 64 65 6b 2d 77 72 61 70 00 00 00 01

encodeKeyWrapAad(fmt=1, tenantId="acme", keyId="k1", keyVer=1, kekKv=2)  → 24 bytes
  46 45 41 43 01 03 00 04 61 63 6d 65 00 02 6b 31 00 00 00 01 00 00 00 02

The suite also pins a multibyte case (column="café" is 5 UTF-8 bytes, not 4 JS code units), a surrogate-pair case (column="🔒" is 4 UTF-8 bytes across a surrogate pair), an over-length guard that raises ContextMismatch, and an fmt-selector case proving the encoder emits the caller-supplied aadFmt at byte index 4 so decrypt rebuilds the exact historical context.

Version axes

Three independent version axes plus one forward-extension seam sit orthogonal to each other. Conflating any two corrupts data. The hardest rule: the crypto scheme is never inferred from the magnitude of a version integer.

ColumnMeaningIn the context?Rotates by
crypto_schemeForward-extension seam. Closed vocabulary ('envelope-v1'); the only thing that selects the decrypt path.noa new value, never a magnitude bump
aad_fmtEncryption-context serialization format (the fmt byte).yes — it is the fmt bytean fmt-migration Workflow
dek_verWhich per-tenant DEK produced this ciphertext (per-tenant, starts at 1).data context onlyDEK rotation (per-tenant)
kek_kvWhich KEK version wrapped a DEK (per-environment).wrap context onlyKEK rotation (per-environment)

dek_ver and kek_kv are strictly separate: the data context never mentions the KEK, and the wrap context never mentions dek_ver. That decoupling is what makes KEK rotation cheap. A single combined version is not used — it would force full data re-encryption on every KEK rotation.

Structural scheme dispatch

crypto_scheme is a dedicated column and the store is single-scheme: the only valid value is 'envelope-v1'. Dispatch is a fail-closed assertion — anything else is rejected withContextMismatch, never routed to a legacy branch and never inferred from a version magnitude. A brand-new tenant legitimately provisions dek_ver = 1, so dek_vermagnitude carries no scheme meaning:

const resolveDataKey = (row: Row) => {
  // Fail closed: envelope-v1 is the only accepted scheme. No epoch switch, no magnitude gate.
  if (row.cryptoScheme !== "envelope-v1") {
    return err(new ContextMismatch("crypto_scheme", "unsupported scheme"));
  }
  return unwrapTenantDek(row.tenantId, row.dekVer);   // per-tenant DEK, mandatory context
};

aad_fmt is stored per row and threaded into the context, so decrypt always rebuilds the context at the row's stored format. Both pre- and post-migration rows decrypt throughout an fmt migration — without the per-row aad_fmt, every legacy row would raiseDecryptFailed.

GCM hardening

The suite is fixed: AES-256-GCM, 256-bit keys, a 128-bit auth tag, and a 12-byte (96-bit) fresh random IV per operation. Two checks are non-negotiable because Web Crypto is otherwise permissive:

  • 128-bit tag, constant. tagLength: 128 is a hard-coded literal on every call — never derived from the stored blob length.
  • 12-byte IV, enforced. decrypt/unwrap reject any stored IV whose byteLength !== 12 with ContextMismatch before callingcrypto.subtle. Web Crypto accepts non-96-bit IVs via a GHASH-derived nonce; validating the length keeps decryption inside the NIST-proven regime even when an attacker controls the stored IV blob.

The IV is fresh getRandomValues(12) on every encrypt and every wrap. The invocation pool under any one key is per-tenant — each tenant's data is encrypted under its own DEK — so the count that matters for the GCM birthday bound is one tenant's rows (thousands), not a deployment-wide total. The ciphertext is stored as one opaque ct || tag BLOB; consumers never slice it.

DEK provisioning

A tenant's first write provisions its DEK through an atomic get-or-create, and the wrapped DEK is committed durably before any ciphertext is written under it. Two isolates racing the first write for a new tenant would otherwise each generate a DEK, encrypt under their own, and collide on PRIMARY KEY (tenant_id, dek_ver=1) — leaving a secret encrypted under a never-persisted key. The sequence:

-- 1. Commit-or-noop the wrapped DEK first. Winner and losers both continue.
INSERT INTO tenant_deks (tenant_id, dek_ver, dek_wrapped, dek_iv, kek_kv, aad_fmt, status, created_at)
VALUES (?, 1, ?, ?, ?, 1, 'active', ?)
ON CONFLICT (tenant_id, dek_ver) DO NOTHING;
-- 2. Re-SELECT the DEK that actually persisted, unwrap THAT one.
SELECT dek_wrapped, dek_iv, kek_kv FROM tenant_deks WHERE tenant_id = ? AND dek_ver = 1;
-- 3. Encrypt the secret under the re-selected DEK, then write the ciphertext row.

A loser discards its own generated DEK, unwraps the winner's, and encrypts under it. A concurrency test runs N parallel first-writes for a fresh tenant and asserts exactly one dek_ver = 1row with all N secrets decryptable.

Rotation

This is crypto-key rotation — re-wrapping or re-encrypting key material. It is distinct from secret-value rotation (connector-supplied staged current/pending/previous storage), which the store does not schedule. Three modes:

ModeWorkCostRecovers from KEK compromise?
Proactive KEK rotationUnwrap each DEK under the old KEK, re-wrap under the new — same DEK plaintext.O(tenants), zero data re-encryptionNo
Compromise-driven rotationGenerate fresh DEKs, re-encrypt all data, purge the compromised kek_kv.O(all rows in the environment)Yes
DEK rotationNew dek_ver, re-encrypt one tenant's secrets.O(that tenant's secrets)n/a (bounds tenant blast radius)

The "O(tenants), zero re-encryption" headline is proactive rotation only — it does not contain a KEK breach. If a KEK leaked and the attacker captured the wrapped-DEK blobs from D1, re-wrapping the identical DEK plaintext under a new KEK changes nothing. Real recovery is compromise-driven rotation: fresh DEKs, full re-encryption, and purging the old kek_kv from that environment's sub-map. Because KEK material is per-environment, recovery is scoped to the compromised environment.

Rotation runs as an async, bounded, resumable Workflow — never a synchronous HTTP handler, which would blow the Worker subrequest and CPU limits mid-rewrap and leave DEKs half-rewrapped. Plaintext never crosses a step boundary; only ciphertext lives in Workflow state. A KEK-map entry (or an olddek_ver) is never pruned until the count of rows still on the old version reaches zero — removing it early would raise KekVersionUnknown and brick those tenants.

Crypto-shred

Dropping a tenant's wrapped DEK (DELETE FROM tenant_deks WHERE tenant_id = ?) and settingtenants.deleted_at makes that tenant's ciphertext undecryptable through the live database in O(1) — no row sweep. That is the erasure primitive.

Crypto-shred completes at max(D1 Time-Travel window, KEK-version retention, DEK-cache TTL)— it is not instantaneous, and not marketed as "O(1), done." D1 Time Travel restores up to 30 days, so anyone with account access can recover the wrapped DEK until that window elapses and exports and replicas age out. A recovered DEK stays unwrappable only while its wrapping kek_kv is still live, so erasure is paired with a KEK-purge step. And an isolate that already unwrapped the DEK holds a usable handle for up to the cache TTL. The store surfaces this true completion time in audit and UX.

The DEK cache

An optional per-isolate LRU cache holds unwrapped, extractable:false DEK handles — never raw bytes, never persisted, never logged, never crossing a step boundary. It defaults to 512 entries and a 300 000 ms TTL measured from insertion, not last access, so a busy tenant's key still expires on a fixed wall-clock window. evictTenant fires on disconnect, crypto-shred, and DEK rotation. To make a shred immediate against handles resident in other isolates, get can be gated on a monotonic per-tenant shred generation; otherwise the cross-isolate residency window equals the TTL.

Error taxonomy

GCM's tag check is a single bit — it cannot distinguish wrong-key from wrong-context from corruption from crypto-shred, and leaking "the context was wrong" would be a relocation oracle. So the taxonomy splits into key-agnostic tag failures and deterministic structural errors raised before anycrypto.subtle call. Errors are plain tagged classes, branched on by _tag, never instanceof.

KindErrorsMeaning
Tag failures (key-agnostic)DecryptFailed, UnwrapFailed, EncryptFailed, WrapFailedA GCM tag failure on data or wrap. These never say why — the ambiguity is deliberate.
SigningKeygenFailed, SignFailed, VerifyFailedSame split: SignFailed never localizes the cause; VerifyFailed is structural only — a signature that simply does not verify is ok(false), not an error.
Deterministic, pre-cryptoContextMismatch, UnsupportedAadFmtContextMismatch: unknown column, iv.byteLength !== 12, dekVer mismatch, over-length field, or malformed tenantId. UnsupportedAadFmt: an aad_fmt with no defined layout — a distinct tag.
Deterministic, pre-cryptoCryptoKeyMissing, InvalidKeyMaterial, KekVersionUnknown, DekVersionUnknownKey material is absent, wrong-length, or names a version that does not resolve.

Runbooks triage with the deterministic errors plus row metadata, because DecryptFailedand UnwrapFailed are intentionally silent about the cause. There is no context-freeencrypt/decrypt overload — the required context argument is a hard compile error at every call site, so a missed call can never silently write relocatable ciphertext. See the full signatures in the API reference, and the trust boundaries in theSecurity model.