Field encryption
One protected key per tenant, and ciphertext moved to another tenant fails to decrypt, provably.
What it is
field-crypto seals values under a distinct AES-256-GCM key per tenant, using HKDF-SHA256 for dev/self-hosted deployments or request-scoped KMS envelope encryption in hosted production. Its self-describing envelope always binds tenant and column identity into the AEAD's additional authenticated data; explicit row-bound fields bind row identity too.
What ships in the module
Browser-safe entry point
Import @caisson/field-crypto/browser inside a client bundle, a Cloudflare Worker, or any other WebCrypto-only runtime for the same HKDF derivation, AES-256-GCM seal and open, row-bound AAD, and envelope codec the server runs, over crypto.subtle and Uint8Array instead of node:crypto and Buffer (Node 20.12 or later). The main entry keeps the full surface including the KMS and Drizzle halves, every browser-entry export is also on it, and both directions of the interop are pinned byte-for-byte against the same fixtures.
Fail-closed on every read and write
encryptedColumn() wires a Drizzle customType whose toDriver/fromDriver only run inside withFieldCryptoContext. Reach an encrypted column with no bound tenant context and currentFieldCryptoContext() throws InternalError instead of returning a partial or unscoped result.
Per-tenant keys, derived or KMS-wrapped
DerivedKeyProvider keeps the zero-infrastructure dev/self-hosted path by folding tenant id and key version into HKDF-SHA256. Caisson's hosted production site instead persists only append-only wrapped DEKs, unwraps every historical version into a disposable request context, and zeroizes all plaintext key buffers at exit.
AAD binds tenant, column, and row
buildAad() serializes a JSON tuple (tenant id, key version, column context, and, for row-bound fields, the row's UUID) as GCM's additional authenticated data. Relocate the ciphertext to another tenant, column, or row and decryption fails as an AEAD authentication error, never a silent wrong-plaintext read.
Self-describing envelope survives rotation
serializeEnvelope() packs format version, algorithm id, and key version ahead of the nonce, ciphertext, and tag into one base64 string; parseEnvelope() reads the version back off the value itself. KeyVersionRegistry.rotate() bumps a tenant forward with no bulk re-encrypt job, older envelopes keep decrypting under the version they were written with.
KMS envelope encryption behind one port
KmsKeyProvider wraps a per-tenant data-encryption key under a KMS-held key-encryption key that never leaves the KMS, only the wrapped DEK is persisted. AWS KMS, GCP KMS, and Azure Key Vault drivers ship behind the same three-method KmsClient port; Caisson's hosted production site uses Azure with required purge protection.
Crypto-shred erasure without breaking the audit chain
cryptoShred() requests KEK deletion through the KMS port and mints an erasure.crypto-shred audit payload that carries no PII plus the provider-proven deletion state. The authorized host must persist and reconcile recoverable receipts; permanent cryptographic erasure is claimed only when the provider proves it irreversible. The WORM-anchored hash chain's committed bytes never change, so verifyChain still passes.
async decryptField(
tenantId: string,
stored: string,
columnContext: string,
): Promise<string> {
const env = parseEnvelope(stored);
const key = await this.provider.keyFor(tenantId, env.keyVersion);
const aad = buildAad(tenantId, env.keyVersion, columnContext);
const cipher = cipherForAlg(env.algId);
let plaintext: Buffer | undefined;
try {
plaintext = cipher.decrypt(
key,
{ nonce: env.nonce, ciphertext: env.ciphertext, tag: env.tag },
aad,
);
return plaintext.toString("utf8");
} finally {
plaintext?.fill(0);
key.fill(0);
}
}- parseEnvelope reads the key version back off the stored value itself, so a ciphertext written under an older version still decrypts after rotation, no migration job, no lookup table.
- buildAad binds tenant and column identity into the AEAD's additional authenticated data, decrypt under the wrong tenant or column and cipher.decrypt throws, it never returns the wrong plaintext.
- The finally block zeroizes both the plaintext buffer and the unwrapped DEK when the call settles, whether it returns or throws. Request-scoped disposal on abort is a separate seam, withKmsFieldCryptoContext, and covers the keys that context owns.