stable

Enclave

The enclave — the trusted region inside the hub where keys and plaintext exist at all: the KEK/DEK/CEK hierarchy, the envelope, sealing, and invariants.

Always-on. The non-negotiable trust boundary. Source of truth: packages/hub/src/capsule/enclave-aes/crypto.ts

An enclave — the term Apple and others use for a restricted, trusted area of a system where privacy and security are structurally ensured — is a place where secrets may exist because nothing outside can reach in. noy-db has one, in software: the region inside @noy-db/hub where keys and plaintext exist at all. noy-db encrypts every record inside the client library, before it reaches any storage backend — the store (a file, a browser database, a cloud bucket) only ever sees ciphertext. The secret-derived KEK, every unwrapped DEK and CEK, and the cleartext of your records are materialized only in the hub's process memory, and only while a session is unlocked — db.close() scrubs them. The code that does this lives in a self-contained folder inside the hub's kernel that owns the keys, the crypto functions, and the encrypted data format.

This page walks the enclave from the outside in: the boundary and its two designed openings, the primitives, how a record is actually encrypted, the three extension layers (users, sealing, per-record keys), the envelope-slot reference and the key-rotation map, the invariants — and, at the very bottom, the dev-only plaintext modes that switch the enclave off for local inspection.

The enclave

The far side of the boundary is everything the hub hands data to. The store receives only the ciphertext envelope; it never sees plaintext, so it can never run your query — the query DSL lives inside the hub and runs after decryption. The same holds for every bridge package the family ships: the to-* storage destinations, the in-* framework bindings, the on-* unlock methods, the as-* exporters, and the by-* session-share transports never see plaintext — the encryption happens in the core before data reaches any of them. Encrypt-before-store is not a policy the store cooperates with; it is structural, and the store stays a dumb, untrusted ciphertext shelf.

Everything is encrypted, then. But a vault nothing could ever leave would be a safe, not a database — so the boundary has exactly two designed openings, and both are deliberate.

at-* — delegated decryption

Sometimes the party that needs to read is not a human with a secret but a machine: a server-side job, a scheduled function, a serverless handler that must process a user's data while the user is asleep. The at-* sealing-key hosts (at-env, at-aws-kms, at-gcp-kms, at-azure-keyvault, at-macos-keychain) exist for exactly this case: a host you control holds a sealing key, and that key unseals what was explicitly sealed to it — with no human present to type a secret.

Be precise about what is unsealed, because there are two grants with very different scopes. A vault-scoped grant (secretMode: 'managed' + sealingKey) seals a machine-generated vault secret to the host, so the host can open — in full — exactly the vaults sealed under its key, and nothing else. A record-scoped grant (sealed-record) seals a single record's per-record key to the host, which can then decrypt exactly that one record, until an expiry, and nothing more. The mechanics of both live in The sealing key below.

Either way, this is the family's single non-zero-knowledge seam — an at-* host can decrypt what it unseals, running on infrastructure you own, by design. The safeguard is scope, not secrecy.

as-* — the designed output channel

Data also flows out — through the as-* exporters, the deliberate, user-invoked exit. Every as-* artefact sits in one of two tiers, and both pass a capability check on the caller's keyring. The plaintext tier (CSV, XLSX, JSON, NDJSON, XML, SQL, blob, ZIP) runs after the hub decrypts and necessarily produces plaintext artefacts — that is the point of an export: an explicit act by a keyholder inside the enclave, gated by the keyring's exportCapability.plaintext grant (a per-format allow-list), not a leak. The bundle tier — the .noydb container (as-noydb) — never leaves ciphertext: record bodies stay encrypted under the vault's own DEKs end-to-end, and recipient access travels as rebuilt keyring slots that wrap the DEK set under a key derived from the recipient's secret (PBKDF2-SHA256, 600,000 iterations), gated by the keyring's exportCapability.bundle grant.

The enclave is also the one part of the kernel that is swappable: a sister project can replace the crypto interior wholesale by implementing the frozen enclave contract — the envelope-in / plaintext-out surface, its refusal semantics, and the wire protocol between the kernel and its crypto core are specified in Forking the enclave, and the architecture of the interior itself in Enclave (architecture). Everything on this page describes the enclave noy-db ships by default.

What the store still learns. Ciphertext is not invisibility. An observer with store access still sees:
  • the plaintext envelope metadata — _v (version counter) and _ts (timestamp) — which the sync engine needs without keys;
  • record counts, ids, sizes, and write timing — that you write and roughly how much, never what;
  • with opt-in deterministic fields, the _det slots' equality classes — which records share a value, acknowledged per collection;
  • with opt-in classified equatable fields, the _bidx tags — the same equality leak, behind its own acknowledgement gate.
The full walk by attacker position is the threat model.

Zero crypto dependencies

All cryptography uses the Web Crypto API — every operation goes through globalThis.crypto.subtle, and random material comes from crypto.getRandomValues. No npm crypto packages are or ever will be installed.

That is a security feature, not a footnote. Cryptographic primitives are the worst possible place for a supply-chain compromise, and the way to shrink that attack surface is to have zero third-party crypto code: nothing to audit beyond the runtime itself, no transitive crypto dependency to pin, patch, or watch for CVEs. The primitives are implemented and maintained by the runtime vendor — the same code path the platform itself trusts — and are hardware-accelerated where the platform provides it. It also forces discipline: the Web Crypto API only offers standard, reviewed constructions, so there is no homegrown cipher anywhere in noy-db.

The full primitive set the enclave uses:

PrimitiveParametersUsed for
PBKDF2-SHA256600,000 iterations, 32-byte saltsecret → KEK derivation
AES-KW (RFC 3394)256-bit keyswrapping DEKs under the KEK (and CEKs under an HKDF-derived wrap key)
AES-256-GCMfresh random 12-byte IV per operationrecord bodies, binary blob chunks
HKDF-SHA256domain-separated salt + info per channelderiving subkeys from a DEK: the CEK wrap key, presence key, sealed-field keys, deterministic-index key
HMAC-SHA256keyed by a DEKblob eTags, presence integrity
SHA-256—content hashes for ledger and bundle integrity
crypto.getRandomValues—IVs, salts, minted keys and secrets

Available in:

  • Node.js 18+
  • Bun
  • Deno
  • Modern browsers
  • Cloudflare Workers
  • Electron / NW.js
  • Mobile WebViews

How a record is encrypted

First, the ideas — in plain words

noy-db is built on zero trust — the idea that you never assume anyone, even your own server, is safe to show secrets to. The term was popularized around 2010 by John Kindervag at Forrester Research, building on earlier "de-perimeterization" work — see Zero trust architecture.

The rest of this page uses a small cast of characters. Each one, in plain words:

  • A key is a big secret number that scrambles a box shut — and unscrambles it again.
  • The DEK (data-encryption key) is the key that locks your actual documents — one per drawer (a collection).
  • The KEK (key-encryption key) is a key whose only job is to lock other keys. It is rebuilt from your secret secret every time you unlock, and it is never written down anywhere.
  • The keyring is the little box of locked-up keys that is safe to store — because without the KEK it is just noise.
  • The IV is a fresh random sprinkle added to every scramble, so the same document never scrambles the same way twice.
  • The envelope is the sealed package the storage actually receives — the scrambled document plus its sprinkle, nothing readable.
  • A CEK (content-encryption key) is an optional extra: every single document gets its own tiny key, so one document can be destroyed — or lent out — on its own.

One honest note before the details: everything above is symmetric encryption — the same key locks and unlocks. The other kind, asymmetric, is a two-part lock: anyone can snap it shut with the public part, but only the private part opens it. noy-db uses asymmetric crypto only in special places — handing one record's key to a machine and signing attestations — the record path below is symmetric throughout.

The chain, precisely

This is the basic, single-secret flow — one user, one secret, no per-record keys. (The extensions — more users, a sealing key, per-record keys — each add one layer on top and are covered in the next three sections.)

Secret
the secret — supplied at unlock, never stored
PBKDF2-SHA256 · 600,000 iterations · 32-byte salt from the keyring
▼
KEK — key-encryption key
memory only · wiped on close()
AES-KW (RFC 3394) unwrap · wrapped DEK read from _keyring/<user>
▼
DEK — data-encryption key, one per collection
memory only while the session is unlocked
AES-256-GCM · fresh random 12-byte IV per write
▼
Envelope
ciphertext in _data + its _iv — the only thing a store ever receives

Reading top to bottom, each term in the chart is one link in the chain:

  • Secret → KEK. The secret you pass as secret to createNoydb() is fed through PBKDF2-SHA256 at 600,000 iterations, salted with a random 32-byte salt, to derive the KEK (key-encryption key). PBKDF2 is a deliberately slow, brute-force-resistant key-derivation function — the iteration count is the cost an attacker pays per password guess. The KEK is never persisted; it lives only in memory during an active session, and db.close() wipes it.
  • The keyring. The salt — and everything else the vault must remember about your access — lives in the keyring, the persisted record at _keyring/<user> inside the vault. It holds your wrapped keys, never usable ones: see Secret & keyring.
  • DEK. Each collection gets its own DEK (data-encryption key), a randomly generated AES-256-GCM key. Your copy of each DEK is stored in the keyring AES-KW-wrapped under your KEK — AES-KW (RFC 3394) is the standard construction for encrypting one key under another. Unlocking a session unwraps the DEKs into memory; they are never persisted unwrapped.
  • Envelope. A record body is serialized to JSON and encrypted with AES-256-GCM — an authenticated cipher: it produces both ciphertext and an integrity tag, so tampering is detected at decrypt time. Every encrypt call uses a fresh random 12-byte IV (initialization vector); an IV is never reused.
  • CEK. A collection can additionally opt into per-record content-encryption keys — one key per record, enabling per-record erasure and sealing — introduced in Per-record keys (CEK) below.

The two directions are the same chain walked in opposite orders:

  1. Encrypting a write — derive the KEK from the secret (once per unlock), unwrap the collection's DEK from the keyring, encrypt the record body under the DEK with a fresh IV, wrap the result in an envelope, and store it. The store receives ciphertext, nothing else.
  2. Decrypting a read — fetch the envelope from the store, authenticated-decrypt _data under the DEK using the stored _iv — the GCM tag is verified as part of decryption — over the body and the record's bound identity and version — so an envelope that does not authenticate throws TamperedError and the read fails closed: there is no partial-decryption fallback (a body sealed before that binding also fails, and reports reason: 'unbound-legacy-format') — then parse the plaintext JSON body.

Envelope

The envelope is the wrapper a store receives instead of your record:

{
  "_noydb": 1,
  "_v":     3,
  "_ts":    "2026-04-04T10:00:00.000Z",
  "_iv":    "<base64>",
  "_data":  "<base64 ciphertext>"
}

_v (version counter) and _ts (timestamp) are NOT encrypted — the sync engine needs them without keys. Everything inside _data IS encrypted.

Users & the keyring

The multi-user extension changes nothing about the chain above — it multiplies one link. The collection DEKs stay the same keys; what a member holds is their own wrapped copy of them, in their own _keyring/<user> record, wrapped under their own KEK (derived from their own secret and salt). Granting a user wraps the DEKs for the new member; revoking one re-wraps the DEKs for the remaining members, so the revoked keyring can no longer unwrap anything current. The keyring is thus both the access-control list and the cryptographic gate: what you can read is exactly what your keyring can unwrap. The single-user primitives live on Secret & keyring, the roles and grant/revoke ceremony on Permissions & keyring, and the multi-user ceremony itself is the opt-in team service.

Collection DEK
one key — every member reads the same ciphertext
AES-KW wrap · once per member, under each member’s KEK
_keyring/alice
wrapped copy
_keyring/bob
wrapped copy
_keyring/…
grant = wrap · revoke = re-wrap the rest

The sealing key

The at-* extension replaces the human at the top of the chart with a host. A sealing-key provider is a tiny contract — NoydbSealer, three members: id, seal(bytes), unseal(bytes) — implemented by each at-* package over whatever the host trusts: an env var, the OS keychain, a cloud KMS.

In managed-secret mode (createNoydb({ secretMode: 'managed', sealingKey })) the hub generates a random 256-bit secret at first open, seals it under the provider, and persists the sealed blob per vault at _meta/sealed-secret; on reopen it checks the stored provider id, unseals, and feeds the secret into the normal PBKDF2 → KEK chain. The user never sees or types it. The scope of this grant is the whole vault: a host holding the sealing key can open exactly the vaults sealed under its key — nothing else, but nothing less. (Managed mode is also the rubber-hose-resistant posture — see session-tiers.)

The record-scoped variant is sealed-record: the grantor unwraps one record's CEK, binds it into {collection, id, cek, expiresAt}, seals that binding via the host's pluggable sealer (RSA-OAEP-SHA256 over a fresh AES-GCM transport key in the shipped providers), and writes the delivery envelope at _sealed_cek/<collection>/<id>/<pid>. The host-side opener verifies the expiry and that the binding names exactly the record it is being used on, then decrypts that one record — it never touches a DEK, and a CEK sealed for record A is rejected against record B. One provider id string identifies each host across every sealed artefact — frozen once shipped (sealing-pid-stability).

Per-record keys (CEK)

By default one per-collection DEK encrypts every record body. Opt a collection into per-record content-encryption keys with vault.collection(name, { perRecordKeys: true }) and each record gets its own AES-256-GCM CEK, AES-KW-wrapped under the collection DEK and stored on the envelope's _cek:

{
  "_noydb": 1, "_v": 3, "_ts": "…",
  "_iv": "<base64>", "_data": "<base64 ciphertext, encrypted under the CEK>",
  "_cek": "<base64 AES-KW-wrapped CEK, wrapped under the collection/tier DEK>"
}
  • Discriminant is _cek presence, not the flag. A record with _cek is decrypted by unwrapping the CEK under the collection DEK then decrypting the body under the CEK; a record without it takes the legacy path (body directly under the collection DEK). So a collection without perRecordKeys is byte-identical to before, and a mixed vault — or a recipient that never set the flag — reads both kinds.
  • Stable across versions. Insert mints the CEK; updates and history snapshots reuse it, so every _history envelope for a record carries the same _cek. (The wrapping key is HKDF-derived from the DEK and the wrap is deterministic, which is what makes the reused _cek byte-identical.) A session-scoped LRU caches the unwrapped CEK per (collection, id).
  • Deterministic index slots stay collection-keyed. A collection can opt specific fields into deterministic encryption (deterministicFields, acknowledged with acknowledgeDeterministicRisk: true): the field value is additionally stored in the envelope's _det map, encrypted so that equal plaintext produces equal ciphertext — enabling blind equality search (findByDet) without decrypting every record, at the cost of leaking which records share a value. _det slots are keyed off a key derived from the collection DEK, never the per-record CEK — cross-record equality only works if all records share the key.
  • Tiers compose. Moving a record between hierarchical access tiers (elevate/demote) re-wraps the same CEK from the source tier DEK to the target tier DEK; the body key is unchanged — the elevation API that drives this re-wrap lives in tiers.
  • Bundles re-wrap. When records leave the vault as a portable bundle (transferable partitions), the re-key step unwraps each _cek under the source DEK and re-wraps it under the fresh destination DEK, so adopted records stay decryptable.
  • Tombstone-tolerant read. A get() on an envelope with no _data/_cek returns null rather than throwing TamperedError.

Per-record keys are the foundation that makes per-record erasure (crypto-shred — see the forget service) and record-scoped sealing (sealed-record) possible: destroy one record's wrapped CEK and only that record becomes undecryptable.

Sealed fields (_sealed)

A collection can additionally pull individual sensitive fields out of the record body: declare them in the collection's sensitive: [...] option and each declared field is encrypted into its own slot in the envelope's _sealed map — an iv:data blob per field — instead of travelling inside _data. A reader who can open _data still cannot see a sealed field without deriving that field's key: each field gets a distinct AES-256-GCM key, HKDF-derived with an injective [domain, collection, field] context, so one field's ciphertext does not authenticate under another field's key — sealed fields are cryptographically isolated from one another, and from the same field name in any other collection.

The field key's parent follows the record's key model:

  • On a perRecordKeys collection the field key derives from the record's CEK (HKDF domain noydb-sealed-cek) — which extends the crypto-shred guarantee to sealed fields: destroying the record's wrapped _cek makes every sealed field unrecoverable along with the body.
  • Without per-record keys it derives from the collection DEK (domain noydb-sealed) — and forget then reports those slots as residue rather than shredded: a synced or backed-up copy stays decryptable as long as the collection DEK survives.

Reads dual-read — the CEK-derived key first, then the DEK-derived key as the legacy fallback — so records sealed before a collection adopted per-record keys stay readable. The ergonomic layer on top is the classified service: declare a field once with a preset and it routes to sealed (recoverable) or digest-only storage, with safe projections and audited reveal.

Envelope slots — the reference

Every layer above writes into the same wrapper. This is the complete set of _-slots a record envelope can carry — five slots every envelope has, and the rest present only when a feature writes them:

SlotWhat it holdsEncrypted?KeyIntroduced by
_noydbformat version markerno—core — every envelope
_vversion counterno — sync orders by it without keys—core — every envelope
_tswrite timestamp (ISO 8601)no — sync needs it without keys—core — every envelope
_ivthe body's fresh random IVno — an IV is not secret—core — every envelope
_datathe record body, ciphertextyescollection/tier DEK — or the record's CEK when _cek is presentcore — every envelope
_byactor id who wrote this versionno—core — stamped when the writer is known
_source / _sourceTsprovenance source id + timestampno—provenance: true — field-metadata
_tierthe record's access tierno — reads route to the right tier DEK without try-decrypting—tiers
_elevatedBywho elevated the record (gates demote)no—tiers
_cekthe record's own key, AES-KW-wrappedyes — a wrapped keywrapped under the collection/tier DEKperRecordKeys — above
_detdeterministic equality index, per fieldyes — deterministic: equal values → equal ciphertext (acknowledged leak)HKDF(DEK) deterministic-index keydeterministicFields — above
_sealedper-field sealed ciphertextyesHKDF per-field key from the CEK (or the DEK for legacy records)sensitive: [...] — above
_vdigclassified verify-digest blobs (digest + reuse ring)yes — AAD-bound to [collection, record, field]HKDF(CEK) vdig slot key — CEK-only, dies with _cekclassified digest-only fields — classified
_bidxclassified equality tag (cost byte ‖ keyed MAC)not reversible ciphertext — a store-visible keyed MACHMAC key HKDF-derived from the DEKclassified equatable — classified
_debugdebug-plaintext marker; fields inlined beside itno — the whole envelope is plaintext—debugPlaintext — below
_deldelete marker (empty body, version-ordered)no—deletes under sync

Two neighbours of the record envelope keep their own slot sets: _history envelopes are ordinary envelopes (same slots — and the same _cek, which is what lets per-record erasure cover history), and blob index objects carry their own _cek/_cekPending wrapped content keys under the _blob DEK (blobs).

Key lifecycle & rotation

Every key above has a rotation story. This is the whole map — the trigger, what actually gets re-encrypted or re-wrapped, and where each ceremony is documented:

What rotatesTrigger / APIWhat is re-wrapped or re-encryptedWhere documented
Secret → KEKdb.team.rotateSecret(vault, input) — user remembers the old one; behind the rotate-secret policy gatea fresh salt and KEK are derived; the keyring's DEKs are unwrapped under the old KEK and re-wrapped under the new one — the DEKs themselves do not changeSecret & keyring
Secret, forgottendb.team.recoverSecret(vault, input) — a recovery proof stands in for the old secretthe DEK set is recovered via the enrolled profile and re-wrapped under the new secret's KEKSecret & keyring · Recovery
Managed secretdb.team.recoverManagedSecret(vault, options) — in managed mode rotateSecret is unavailable; recovery is the sanctioned resetthe machine-minted secret is replaced and re-sealed under the sealing-key provider (the provider itself is unchanged)Recovery
Collection DEKsdb.rotate(vault, collections) — other members lose access to the rotated collections; or db.revoke(vault, { userId }), which always rotates when a member is removedfresh DEKs are minted, every record in each collection is re-encrypted, and only the caller's keyring gets the new DEKs — every other member's entries for those collections are dropped and must be re-grantedPermissions & keyring
HKDF-derived subkeysautomatic — the presence key, the deterministic-index key, and the DEK-derived sealed-field keys are HKDF children of the collection DEKthey rotate whenever their parent DEK rotates; a revoked user cannot derive the new presence keythis page — primitives
A record's CEKvault.rotateRecordCek(collection, id) — directly, or via revokeSealedRecord(…, { hard: true })the live body is re-encrypted under a fresh CEK and every _sealed_cek delivery envelope for the record is deleted; history versions keep their original CEKsealed-record
Recovery materialdb.team.rotateRecovery(vault, options) — deliberate paper/Shamir regenerationthe enrolled recovery entry is replaced — a reissued paper sheet or a re-shared Shamir entryRecovery
Broker seedvault.broker().rotate()a new seed is registered before the old is dropped, with a grace window for in-flight proofsbroker
Sealing provider id — never—a provider's id string is frozen once shipped; every sealed artefact names it foreversealing-pid-stability

Invariants

These are the bright lines, ordered from the outermost boundary inward. Any change to one of them is a security review.

InvariantEnforcement
Stores see only ciphertextEncryption happens in Collection.put before the adapter call. No code path puts plaintext into a store.
Authenticated decryption fails closedAn envelope that does not authenticate throws TamperedError; no partial-decryption fallback. TamperedError is not by itself proof of an attack — read err.reason, which distinguishes a genuine tamper from a record written before the 0.6.0-pre.18 format change.
Per-record CEK is stable + DEK-scopedUnder perRecordKeys, a record's CEK is reused across all its versions/history and is only ever AES-KW-wrapped under the collection/tier DEK (wrapCek/unwrapCek in capsule/enclave-aes/crypto.ts). _det slots are never CEK-keyed.
AES-256-GCM with random 12-byte IV per opcapsule/enclave-aes/crypto.ts generates IVs via crypto.getRandomValues(new Uint8Array(12)). Never deterministic (except opt-in _det slots, whose equality leak is acknowledged per collection).
AES-KW for DEK wrappingStandard RFC 3394. No custom KDF.
KEK never on diskOnly _keyring/<user> records persist (which carry wrapped DEKs, not the KEK).
PBKDF2-SHA256 600,000 iterationsderiveKey() constant. Lowering this is a breaking security change.

Plaintext modes

Two dev-only escape hatches switch the enclave off. Both are explicit, loud, and never for production or client data.

Plaintext mode (encrypt: false)

createNoydb({ encrypt: false }) skips the crypto path entirely. Records are stored as raw JSON in _data. Use only for testing / debugging — no privacy guarantees.

Debug-plaintext layout (debugPlaintext)

Zero-knowledge encryption is the default. During local development, though, you sometimes want to point your store's native tools straight at the data. Open the vault with encrypt: false and debugPlaintext: true:

import { createNoydb } from '@noy-db/hub'
import { toFile } from '@noy-db/to-file'

const db = await createNoydb({
  store: toFile({ dir: './data' }),
  user: 'dev',
  encrypt: false,
  debugPlaintext: true,
})

This changes the on-store layout so it's directly legible:

  • Records are written with their fields inlined beside the envelope metadata (_debug: 1, empty _data) instead of stuffed into _data as a JSON string — so jq '.total' over a to-file object resolves directly.
  • Blobs are written as a single un-gzipped object — the chunk's base64 _data decodes straight to the original bytes (base64 -d).

To unwrap an envelope programmatically (the core of a noydb cat script), use readPlaintextRecord(envelope) — it handles both the inlined and the classic plaintext layouts, and throws on an encrypted envelope:

import { readFileSync } from 'node:fs'
import { readPlaintextRecord } from '@noy-db/hub/debug'

const env = JSON.parse(readFileSync('data/acme/invoices/inv-1.json', 'utf8'))
readPlaintextRecord(env)
// → { id: 'inv-1', total: '120.00', … }

Guardrails:

  • debugPlaintext: true combined with encryption throws DebugPlaintextError at construction — it is an unencrypted-only inspection mode.
  • Opening a vault in this mode prints a loud console.warn.
  • A CI architecture check (no-debug-plaintext-in-source) forbids hardcoding debugPlaintext: true in shipped library source.

Never use this for production or client data. It stores everything in cleartext and is not crypto-shreddable.

Encryption elsewhere in the family

The record path above is not the only place the family does real cryptography. The same zero-dependency Web Crypto discipline covers every one of these:

  • Blobs. Binary attachments are chunked and each chunk encrypted with AES-256-GCM under a dedicated _blob collection DEK, with the chunk's parent blob and position bound in as additional authenticated data ({eTag}:{chunkIndex}:{chunkCount}) — so chunks cannot be reordered, substituted, or truncated undetected. The dedup address is eTag = HMAC-SHA256(blob DEK, plaintext), keyed so an observer with store access cannot pre-compute eTags for known files. See blobs.
  • Magic links. on-magic-link grants read-only access from a single-use URL that carries only an opaque token — no key material. Server and client each derive the same viewer KEK via HKDF-SHA256 from a server-held secret salted with the token's hash; that KEK unlocks a purpose-built viewer keyring directly (bypassing PBKDF2), so it covers only the DEKs granted to the viewer.
  • Credential broker. The broker authenticates a client to a remote credential-minting host without the host ever holding a secret: a 32-byte seed is stored encrypted in the reserved _broker collection under its own DEK, an HKDF-SHA256-derived proof key HMAC-SHA256-signs the host's challenge, and the host verifies the signature (constant-time) against a short-TTL challenge.
  • Unlock factors. Each on-* method wraps or proves with real primitives of its own: on-webauthn derives an AES-GCM wrapping key via HKDF-SHA256 from the passkey's PRF output (falling back to the credential's rawId on authenticators without PRF); on-password wraps the DEK set under PBKDF2-SHA256 (600,000 iterations); on-pin quick-unlock wraps only the session's cached DEK map (never the KEK) under a PIN-derived AES-GCM key (PBKDF2, 100,000 iterations, attempt-bounded + TTL — see session-tiers); on-oidc XOR-splits the key payload into a device half (HKDF-derived from a per-device random secret that never leaves the device) and a server half encrypted under a key derived from the ID token; on-totp is RFC 6238 HMAC; on-email-otp stores only salted SHA-256 digests of codes; on-threat detects duress secrets via a PBKDF2 hash compared in constant time.
  • Shamir secret sharing. on-shamir splits the KEK into k-of-n shares with genuine Shamir secret sharing over GF(2⁸) — any k shares reconstruct it by Lagrange interpolation; fewer than k leak zero bits.
  • Attestations. The attestation package signs selective-disclosure attestations with Ed25519 over a canonical core of salted SHA-256 field hashes — a verifier checks that a record contained given field values without seeing the others.
  • Classified blind index. classified fields marked equatable get an HMAC-SHA256 equality tag under an HKDF-derived index key — searchable without decryption. (The related deterministic _det slots are covered under Per-record keys above.)
  • Integrity chains. The audit periods ledger is a tamper-evident SHA-256 hash chain — integrity, not secrecy — and the forget subject index pseudonymizes subject ids as HMAC-SHA256 under a dedicated index DEK.
  • Encrypted ZIP export. as-zip can optionally encrypt the archive it produces in the standard WinZip-AES-256 format under a user-supplied password — protection for the exported file, orthogonal to the vault's own keys.

The by-* session-share transports (by-peer, by-tabs) deliberately have no crypto of their own — they carry the already-encrypted envelopes and rely on the transport layer (DTLS, same-origin) for the wire, exactly as the enclave boundary intends.

See also