Provenance

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

To 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

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