field-crypto
Per-tenant field encryption with wrapped random DEKs or self-hosted HKDF derivation, behind a pluggable FieldKeyProvider KMS port.
@caisson-sh/field-crypto encrypts individual fields under a distinct tenant key. A KMS-backed
deployment wraps each tenant's data-encryption key under a tenant-specific key-encryption key;
the dev/self-hosted zero-infra path can instead derive tenant keys with HKDF-SHA256 from an
environment-held master key. One tenant's key cannot decrypt another tenant's ciphertext,
the isolation is in the key material and authenticated envelope, not in an application check
that could be skipped.
What it does
- Per-tenant field encryption: encrypt and decrypt named fields (a patient SSN, a bank token) scoped to a tenant. The plaintext is never stored.
- Two key-provider modes: hosted KMS deployments generate a random DEK per tenant/version and persist only its wrapped form; the zero-infra dev/self-hosted path derives per-tenant subkeys deterministically from a root key with HKDF-SHA256.
- AAD-bound AES-256-GCM envelope: every ciphertext binds tenant, key version, and column (and, for row-bound fields, row id) as GCM's additional authenticated data. A self-describing envelope carries its own format/algorithm/key-version header.
- Key-version rotation with no bulk re-encrypt: bump a tenant's current version; older envelopes keep decrypting under the version they were written with.
- Crypto-shred erasure: request deletion of a tenant or subject's key-encryption key through the KMS port without mutating an append-only audit chain. Recoverability follows the provider's deletion receipt: soft-deleted keys remain recoverable until their retention window closes or an irreversible purge succeeds.
Install
bun add @caisson-sh/field-cryptoQuickstart
The zero-infra dev/self-hosted path derives keys from an env-held master secret, no key table, no KMS call:
Caisson's hosted production site does not use this path. Its BYOK seal path requires Azure Key Vault and fails closed when the vault, credentials, or purge-protection contract is unavailable.
# 32-byte values, hex-encoded (64 chars)
MASTER_FIELD_KEY=...
FIELD_CRYPTO_SALT=...import { DerivedKeyProvider, TenantFieldCrypto } from "@caisson-sh/field-crypto";
const provider = DerivedKeyProvider.fromEnv(); // reads MASTER_FIELD_KEY + FIELD_CRYPTO_SALT
// Not named `crypto`: that would shadow the global WebCrypto object this page later tells you to
// call for `crypto.randomUUID()` row ids.
const fieldCrypto = new TenantFieldCrypto(provider);
const sealed = await fieldCrypto.encryptField(
tenantId,
"424-00-1234",
"patient.ssn",
);
const plain = await fieldCrypto.decryptField(tenantId, sealed, "patient.ssn");
// A different tenant's id derives a different subkey → fails closed.
await fieldCrypto.decryptField(otherTenantId, sealed, "patient.ssn"); // throws: AEAD authentication errorThe guarantee
There is no shared field key and no path where tenant B's key decrypts A's data. In the derived
dev/self-hosted mode, tenant A's key is a pure function of the root key, A's id, and the key
version. In hosted KMS mode, each random DEK is recovered from its append-only wrapped row using
the exact Azure KEK version recorded in the wrapped payload. In either mode, ciphertext relocated
to another tenant or column fails authentication rather than returning the wrong plaintext.
Explicit row-bound encryptField calls also reject same-column relocation between rows; the
transparent Drizzle column path has no row identifier and does not claim that stronger binding.
The Drizzle column path
For application code that reads/writes through Drizzle, encryptedColumn is transparent:
encrypt on write, decrypt on read, driven by an AsyncLocalStorage tenant context you bind
alongside your RLS withTenant call (the encryption boundary equals the RLS tenant
boundary):
import {
encryptedColumn,
withFieldCryptoContext,
derivedContext,
} from "@caisson-sh/field-crypto";
const patients = pgTable("patients", {
id: uuid("id").primaryKey(),
ssn: encryptedColumn("patient.ssn")("ssn"),
});
await withFieldCryptoContext(derivedContext(provider, tenantId), () =>
db.insert(patients).values({ id, ssn: "424-00-1234" }),
);Reach an encrypted column with no bound context and currentFieldCryptoContext() throws:
fail-closed, never a partial or unscoped read.
Row-bound fields
Fields that need cross-row tamper-evidence (any SEC/HIPAA-sensitive column) use the explicit
row-bound path instead of the transparent column, rowId must be a client-minted
crypto.randomUUID() primary key, known before the insert:
import { encryptField, decryptField } from "@caisson-sh/field-crypto";
const sealed = encryptField(ctx, "patient.ssn", rowId, "424-00-1234");
const plain = decryptField(ctx, "patient.ssn", rowId, sealed);Rotation
Rotate through the registry the provider actually reads. DerivedKeyProvider.fromEnv() constructs
its own KeyVersionRegistry unless you inject one, so a freestanding registry bumps a map nothing
consults and no write ever picks up the new version:
provider.registry.rotate(tenantId); // bumps the current version; new writes use it immediatelyTo own the registry yourself, inject it at construction and rotate on that same instance:
import { DerivedKeyProvider, KeyVersionRegistry } from "@caisson-sh/field-crypto";
const registry = new KeyVersionRegistry();
const provider = DerivedKeyProvider.fromEnv(process.env, registry);
registry.rotate(tenantId);There's no bulk re-encrypt job, every envelope carries its own key_version, so a value
written under an older version keeps decrypting until its next write lazily re-encrypts it
under the current one.
The KMS port
The FieldKeyProvider port is the seam: DerivedKeyProvider (above) is the zero-infra
dev/self-hosted choice; 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 clients ship today behind the same three-method KmsClient port.
Caisson's hosted production site wires the Azure client; it never falls back to a derived or demo
key after a KMS failure.
import {
KmsKeyProvider,
PgWrappedKeyStore,
withKmsFieldCryptoContext,
} from "@caisson-sh/field-crypto";
// `tenantScopedKmsClient` maps the logical tenant scope to a provider KEK.
// Caisson's hosted adapter does this with a deterministic Azure key name.
const provider = new KmsKeyProvider(
tenantScopedKmsClient,
new PgWrappedKeyStore(tx),
);
await provider.ensureProvisioned(tenantId);
await withKmsFieldCryptoContext(provider, tenantId, async (ctx) => {
await writeEncryptedFields(tx, ctx);
});Use PgWrappedKeyStore inside an RLS-scoped tenant transaction to persist wrapped DEKs
append-only. Call ensureProvisioned() before you bind — a bind reads the current key version, so
it allocates for read-only callers too — and bind the provider with
withKmsFieldCryptoContext(). The bind unwraps every historical version for the request,
then zeroizes every plaintext DEK in finally; old ciphertext stays readable after rotation
without a process-lifetime plaintext-key cache.
Crypto-shred erasure
cryptoShred requests destruction of a tenant or subject's KEK through the KMS port and mints an
erasure.crypto-shred audit payload that carries no PII. Ciphertext becomes irrecoverable only
when the provider receipt proves the key is no longer recoverable; an Azure soft-delete receipt
alone does not make that claim. The append-only WORM-anchored audit chain's committed bytes never
change, and verifyChain still passes after the shred, because the chain only ever committed the
ciphertext envelope, never plaintext.
import { cryptoShred } from "@caisson-sh/field-crypto";
const { auditPayload } = await cryptoShred(kmsProvider, {
keyScopeId: subjectId,
tenantId,
subjectId,
reason: "gdpr-art17",
occurredAt: new Date().toISOString(),
});Configuration
Dev and self-hosted derived-key path
| Var | What | Notes |
|---|---|---|
MASTER_FIELD_KEY | 32-byte IKM, hex (64 chars) | Hard secret, read once, never logged. |
FIELD_CRYPTO_SALT | 32-byte per-deployment salt, hex (64 chars) | Non-secret; cross-deployment domain separation. |
Only required for the DerivedKeyProvider dev/self-hosted path.
Caisson hosted production
| Var | Required value / purpose |
|---|---|
AZURE_KEY_VAULT_URL | HTTPS Azure Key Vault URL |
AZURE_KEY_VAULT_KEY_NAME | Prefix for deterministic per-tenant KEKs |
AZURE_KEY_VAULT_WRAP_ALGORITHM | Exactly RSA-OAEP-256 |
AZURE_KEY_VAULT_PURGE_PROTECTION | Exactly enabled; runtime also verifies the key recovery mode |
AZURE_TENANT_ID | Required; the service principal the adapter authenticates as |
AZURE_CLIENT_ID | Required; the service principal the adapter authenticates as |
AZURE_CLIENT_SECRET | Required; the service principal's secret |
The hosted adapter creates Azure SDK clients through an explicit ClientSecretCredential built
from those three required variables. It never falls back to an ambient credential chain, so a
missing or misspelled variable fails closed when the client is constructed rather than silently
authenticating as whatever identity the host happens to offer. It provisions the tenant KEK when a KMS context is first
bound — including a read-only one, since binding reads the current key version — pins every wrapped
DEK to the exact Azure KEK version returned by the wrap operation, persists only wrapped DEKs in the
tenant-scoped Postgres store, and bounds each provider request. Self-hosted integrations construct
their chosen KMS client (createAwsKmsClient, createGcpKmsClient, or
createAzureKeyVaultKmsClient) and supply their own credential adapter.
Composing with the base
The encryption boundary equals the RLS tenant boundary (@caisson-sh/tenancy-rls): bind
withFieldCryptoContext alongside withTenant so a query can never touch an encrypted
column outside its tenant scope. cryptoShred is designed to sit under @caisson-sh/audit-worm:
the shred receipt's audit payload is meant to be appended to the WORM chain by the caller,
so an erasure is recorded without ever mutating the chain's committed bytes.
Not a compliance certification
field-crypto ships the technical control HIPAA and SOC 2 point at for data at rest, a distinct key per tenant and cryptographic proof a ciphertext can't cross tenant boundaries. It does not make an organization compliant; that determination is your organization's and its auditor's to make.
field-crypto works standalone, or as one of the primitives composing the Compliance and
Provenance families, alongside @caisson-sh/audit-worm, @caisson-sh/signing-primitive,
@caisson-sh/retention-runner, and the alert pipeline.
Migrating from pre-1.0
The current release is 1.1.1. Three API changes landed in 1.0.0 and nothing since has
moved them: the request context lends key material instead of returning it, WrappedKeyStore
gained an atomic first-write member, and @caisson-sh/ai-kit's buildRunTools takes a context
runner instead of a key provider. The wire format did not change, so no stored ciphertext
needs re-encrypting, an envelope written by a pre-1.0 build opens under 1.1.1 unchanged.
deriveKey(version) became withKey(version, use)
FieldCryptoContext.deriveKey(keyVersion) returned a key buffer the caller held until the
request ended, one live copy per call. It is replaced by withKey(keyVersion, use), which
lends the key to one operation and zeroizes it when that operation returns or throws.
If your code calls sealField, openField, or reads and writes through encryptedColumn,
there is nothing to change, those moved to withKey internally. Direct key callers wrap the
body instead:
import { aesGcm, buildAad, serializeEnvelope } from "@caisson-sh/field-crypto";
const keyVersion = ctx.currentVersion();
const aad = buildAad(ctx.tenantId, keyVersion, "patient.ssn");
// Before (pre-1.0): the buffer is yours, and stays live for the rest of the request.
const key = ctx.deriveKey(keyVersion);
const { nonce, ciphertext, tag } = aesGcm.encrypt(key, Buffer.from(plaintext, "utf8"), aad);
const sealed = serializeEnvelope({ algId: aesGcm.algId, keyVersion, nonce, ciphertext, tag });After — the same body, moved inside the lend:
import { aesGcm, buildAad, serializeEnvelope } from "@caisson-sh/field-crypto";
const keyVersion = ctx.currentVersion();
const aad = buildAad(ctx.tenantId, keyVersion, "patient.ssn");
const sealed = ctx.withKey(keyVersion, (key) => {
const { nonce, ciphertext, tag } = aesGcm.encrypt(key, Buffer.from(plaintext, "utf8"), aad);
return serializeEnvelope({ algId: aesGcm.algId, keyVersion, nonce, ciphertext, tag });
});The callback must be synchronous. An async or otherwise promise-returning callback is a
type error at bun run check, and it is also refused at runtime with an error naming the
misuse, because the key is wiped before the continuation would resume, and encrypting under
the resulting all-zero key round-trips successfully and would otherwise fail silently. A
callback returning an array or any other non-thenable value passes through unchanged.
Building a context has not changed, derivedContext(provider, tenantId) and
withKmsFieldCryptoContext(provider, tenantId, fn) produce contexts that already implement
withKey. If you hand-rolled a FieldCryptoContext, swap the member and wipe what you lend:
import type { FieldCryptoContext } from "@caisson-sh/field-crypto";
const ctx: FieldCryptoContext = {
tenantId,
withKey(keyVersion, use) {
const key = provider.deriveKey(tenantId, keyVersion);
try {
return use(key);
} finally {
key.fill(0);
}
},
currentVersion: () => provider.currentVersionSync(tenantId),
};FieldKeyProvider.keyFor is unchanged, and SyncFieldKeyProvider.deriveKey keeps its
signature. Its contract tightened: it must return fresh, caller-owned material, because
derivedContext now zeroizes the returned buffer in place with no defensive copy. A provider
that hands back a cached buffer has that cache wiped by the first operation, so the second
operation is rejected with an all-zero-key error rather than encrypting under a publicly
known key. A KMS provider that unwraps to an all-zero DEK is rejected at bind for the same
reason.
WrappedKeyStore requires putWrappedIfAbsent
External implementations of WrappedKeyStore add one member. It inserts only when the tenant
and version have no wrapped DEK yet, and returns true for the winner and false when an
append-only winner already exists, which is what elects a single first-provision writer under
concurrent binds:
import type { WrappedKeyStore } from "@caisson-sh/field-crypto";
class MyWrappedKeyStore implements WrappedKeyStore {
// getWrapped / putWrapped / currentVersion / setCurrentVersion are unchanged.
async putWrappedIfAbsent(
tenantId: string,
keyVersion: number,
wrapped: Buffer,
): Promise<boolean> {
// INSERT ... ON CONFLICT (tenant_id, key_version) DO NOTHING
// → true when this statement inserted the row, false when it hit the conflict.
}
}InMemoryWrappedKeyStore, DbWrappedKeyStore, and PgWrappedKeyStore already implement it.
If you inject your own KeyValueStore into DbWrappedKeyStore, that port grew from get and
put to four members: add putIfAbsent(key, value), returning whether it inserted, and
compareAndSwap(key, expected, next), returning whether it swapped, where an expected of
undefined means the key must not already exist. Both back the monotonic current-version
transitions that stop an overlapping recovery writer from lowering a newer durable version.
buildRunTools takes a fieldCryptoContext runner
In @caisson-sh/ai-kit, RunToolsDeps.keyProvider (a SyncFieldKeyProvider) is removed and
replaced by a required fieldCryptoContext runner. Callers still passing a key provider do
not compile. The runner receives an account id and a callback, and binds the tenant executor
and the field-crypto context together in one transaction, so parked_state is sealed under a
context that retains no plaintext key between requests:
import { buildRunTools } from "@caisson-sh/ai-kit";
import {
KmsKeyProvider,
PgWrappedKeyStore,
withKmsFieldCryptoContext,
} from "@caisson-sh/field-crypto";
import { withTenant } from "@caisson-sh/tenancy-rls";
const tools = buildRunTools({
...deps, // tx, settings, resolveModel, guard, lane, agentId, tools, maxSteps, creditBudget
fieldCryptoContext: (accountId, fn) =>
withTenant(tx, accountId, async (exec) => {
const provider = new KmsKeyProvider(kmsClient, new PgWrappedKeyStore(exec));
await provider.ensureProvisioned(accountId);
return withKmsFieldCryptoContext(provider, accountId, (ctx) => fn(exec, ctx));
}),
});A deployment on the derived path supplies the same runner shape with
derivedContext(provider, accountId) in place of the KMS bind.
Check your rotation depth before you upgrade
A KMS context prefetches every historical key version at bind, and 1.0.0 bounded that at
maxPrefetchVersions, default 64. A tenant rotated past the cap now fails the bind with an
error naming both the depth and the limit, where before it ran until it hit the request
deadline. Read each tenant's current version first; raise the bound through the
maxPrefetchVersions option on kmsContext or withKmsFieldCryptoContext only with evidence
that the KMS request budget still holds at that depth.
Everything else on the surface is additive. 1.1.0 added the @caisson-sh/field-crypto/browser
entry and its …Async and …Bytes names alongside the existing exports, over the same wire
format, so a value sealed in a browser opens under decryptField and the reverse.
audit-worm
S3/GCS/R2 Object-Lock WORM storage plus an append-only SHA-256 audit chain and a derived-current locked-version table, evidence that cannot be altered before retention expires, and tampering that is provable.
Everything
The full Caisson catalog, every family and every à-la-carte module, all Apache-2.0.