field-crypto
Per-tenant field encryption with wrapped random DEKs or self-hosted HKDF derivation, behind a pluggable FieldKeyProvider KMS port.
@caisson/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/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/field-crypto";
const provider = DerivedKeyProvider.fromEnv(); // reads MASTER_FIELD_KEY + FIELD_CRYPTO_SALT
const crypto = new TenantFieldCrypto(provider);
const sealed = await crypto.encryptField(
tenantId,
"424-00-1234",
"patient.ssn",
);
const plain = await crypto.decryptField(tenantId, sealed, "patient.ssn");
// A different tenant's id derives a different subkey → fails closed.
await crypto.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/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/field-crypto";
const sealed = encryptField(ctx, "patient.ssn", rowId, "424-00-1234");
const plain = decryptField(ctx, "patient.ssn", rowId, sealed);Rotation
import { KeyVersionRegistry } from "@caisson/field-crypto";
const registry = new KeyVersionRegistry();
registry.rotate(tenantId); // bumps the current version; new writes use it immediatelyThere'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/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/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/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/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 is sold standalone, or as one of the primitives composing the Compliance
bundle alongside @caisson/audit-worm, @caisson/retention-runner, and the alert pipeline.
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 bundle and every à-la-carte module, one purchase.