Provenance

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-crypto

Quickstart

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 error

The 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 immediately

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/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

VarWhatNotes
MASTER_FIELD_KEY32-byte IKM, hex (64 chars)Hard secret, read once, never logged.
FIELD_CRYPTO_SALT32-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

VarRequired value / purpose
AZURE_KEY_VAULT_URLHTTPS Azure Key Vault URL
AZURE_KEY_VAULT_KEY_NAMEPrefix for deterministic per-tenant KEKs
AZURE_KEY_VAULT_WRAP_ALGORITHMExactly RSA-OAEP-256
AZURE_KEY_VAULT_PURGE_PROTECTIONExactly enabled; runtime also verifies the key recovery mode
AZURE_TENANT_IDRequired; the service principal the adapter authenticates as
AZURE_CLIENT_IDRequired; the service principal the adapter authenticates as
AZURE_CLIENT_SECRETRequired; 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.