stable

Forking the enclave

The frozen-barrel contract a sister project implements to replace noy-db's crypto interior — surface, refusal semantics, conformance kit, body-access ratchet.

noy-db is an encrypted, offline-first document store: every record is encrypted inside the @noy-db/hub package before it reaches any storage backend. The enclave is the one folder inside the hub's kernel where that crypto actually lives — see Enclave for why it is the one swappable core. This page is the contract for actually swapping it: what a fork replaces, what it must keep, and how it proves conformance.

Audience: a sister project that wants noy-db's spine (the always-on kernel orchestration code), services, and wire protocol, but its own crypto engine, in-memory protection policy, or key story.

Read first: Enclave — the security invariant this contract protects, and the nested-cores level-0 arrow-flip it exercises (the spine consumes the enclave's frozen barrel, never the reverse). The reference enclave's own behavior is documented in Core 02 — Enclave; the family-level frame is Families.

What a fork replaces

Exactly one folder: packages/hub/src/capsule/enclave-aes/ — nothing else. That folder is noy-db's crypto interior:

  • crypto.ts — the Web Crypto primitives (AES-256-GCM, PBKDF2-SHA256, AES-KW, HKDF, SHA-256/HMAC-SHA-256).
  • primitives.ts — those primitives assembled into the CapsulePrimitives surface that makeCapsule() consumes (below).
  • classify/ — the verify-without-reveal primitives (value digests, blind-index tags, constant-time compare, k-of-n evaluation) consumed by the classified service.
  • broker/, bound.ts, capabilities.ts — the capsule's bound form and the capability set it declares.
  • index.ts — the barrel: the only file the rest of the hub is allowed to import from this folder (scripts/check-architecture.mjs's enclave-barrel-only check enforces that mechanically).

⭐ The envelope format is NOT in that folder — capsule/plumbing is shared

A fork is smaller than it looks, and smaller than this page used to say. The per-record codec, record-identity AAD, tombstones, sealed slots, rekeying, signing and deterministic-slot handling — roughly three quarters of what used to sit inside the enclave — live in capsule/plumbing/, because they encode hub's envelope format, not anyone's cipher, and are therefore identical for every capsule.

So a fork does not reimplement them. It supplies CapsulePrimitives (capsule/contract.ts) — encrypt/decrypt, the deterministic and sealed-field key derivations, generateDEK, wrapCek/unwrapCek, and the classify mint/open pair — and makeCapsule() returns the whole surface built on top.

⚠️ That is a boundary worth reading before you plan a fork. Replacing the cipher is a CapsulePrimitives implementation; changing the envelope is a change to hub's format, which is a family-wide seam and not a fork-local decision at all.

A fork deletes this folder and drops in its own implementation — a different KDF, a hardware-backed keystore, a post-quantum wrap algorithm, a keyless HSM-backed design — as long as the replacement re-exports the same barrel (below). Nothing outside capsule/enclave-aes/** needs to change, and capsule/enclave-aes/** itself imports nothing from with-* services — the self-containment guarantee, enforced by the same architecture check in the reverse direction — so there's no tangle to unwind on the way out.

What a fork keeps

Everything outside capsule/enclave-aes/** is family-owned and untouched by a fork:

  • The envelope protocol header — _noydb, _v, _ts, _by, _source, _sourceTs, _tier, _elevatedBy. This is wire protocol, not protection: stores, the sync engine, history, and klum's cross-vault orchestration all read these fields directly, on every enclave. Only the protected body (_iv, _data, _cek, _det, _sealed, _debug) is enclave territory (see "The body-access ratchet" below) — a fork's enclave owns those fields' meaning, the header's meaning is fixed by the family.
  • The spine — Noydb → openVault() → Vault → vault.collection<T>() → Collection, the owner keyring, schema/refs, basic query.
  • The services — every with-* subsystem (history, blobs, sync, aggregate, crdt, team, …) and its with<Name>() strategy seam.
  • The ports — to-* storage destinations, in-* framework bindings, on-* unlock primitives, as-* export formats, by-* transports, at-* sealing-key providers. None of these see plaintext or the protected body directly; they call through the collection/vault API or (for to-*) receive ciphertext envelopes verbatim.

The contract surface

capsule/enclave-aes/index.ts is the frozen barrel — every symbol below must exist in any fork's replacement folder, with the same name and shape. Frozen by packages/hub/__tests__/enclave-surface-golden.test.ts against enclave-surface.golden.json (49 value exports + 5 type exports today). Additive changes only — the family can add a new export in a later release; removing or renaming one is breaking for every fork.

  • Key type — EnclaveKey (= CryptoKey in the reference enclave). The opaque handle every barrel-facing signature traffics in. A fork redefines the alias to its own representation (type EnclaveKey = null for a keyless design) and the spine type-checks against that keyless world at fork-compile time — outside the enclave, EnclaveKey is never constructed, inspected, or serialized, only passed between barrel calls.
  • Crypto ops — encrypt/decrypt (+ Bytes, BytesWithAAD, Deterministic variants), sha256Hex, hmacSha256Hex.
  • Key lifecycle — deriveKey/deriveSecretKey (PBKDF2-SHA256 → AES-KW / AES-GCM; usage typed by SecretKeyUsage), generateDEK, wrapKey/unwrapKey, wrapCek/unwrapCek/importCek, derivePresenceKey, deriveDeterministicKey, deriveSealedFieldKey/deriveSealedFieldKeyFromCek, resolveStableCek/rewrapBodyToDek, base64 helpers, IV/salt generators.
  • Record codec — RecordCodec, the per-record encode/decode engine (+ the SealedShredSlot type).
  • Sealing — sealRecordToHost/revokeSealedRecord/rotateRecordCek, SealingContext, SEALED_CEK_NS. Sealing grants an at-* host you control decrypt access to a scoped slice of a record.
  • Deterministic — findByDet/queryByDet, DeterministicContext — blind-equality lookup over deterministically-encrypted fields.
  • Tombstone — isTombstone/buildTombstone — the residue an erased record leaves behind.
  • Classify — deriveVdigSlotKey, pbkdf2VerifyDigest, ctEqualTags, evaluateKofN (verify-digest primitives) and deriveClassifyIndexKey, deriveClassifyIndexSalt, mintBidxTag, computeBidxTarget (blind-index primitives). The verify/match orchestration around them lives in the classified service and is not part of the fork contract — only these eight primitives are.
  • Envelope body (the protected-body access contract) — the 4 body helpers, the sanctioned door onto _iv/_data/_cek/_sealed for everyone outside the folder:
    • openEnvelopeJson(env, key, opts?): Promise<string> — decrypt (or pass through, in plaintext mode) the body to its JSON text.
    • writeEnvelopeBody(json, key, opts?): Promise<Pick<EncryptedEnvelope, '_iv' | '_data' | '_cek'>> — produce the protected-body fields for callers assembling envelopes.
    • hasPerRecordKey(env): boolean — _cek !== undefined, without the caller touching _cek itself.
    • envelopeBodyForHash(env): string — canonical body-bytes accessor for the ledger hash chain.

Refusal semantics — CapsuleNotSupportedError

The barrel is frozen, but four groups are optional: sealing, deterministic, per-record-keys (wrapCek/unwrapCek), and classify. A fork whose enclave doesn't implement one of these refuses it by throwing CapsuleNotSupportedError (code 'CAPSULE_NOT_SUPPORTED', exported from @noy-db/hub/capsule, carrying the refused group) from every function in that group.

Since hub 0.10.0-pre.2 this is the canonical refusal, and hub itself throws it: a capsule that lacks a group is refused at startup — by createNoydb() for an enabled service, by vault.collection() for a declared field form, and for the authenticate/seal posture groups — before any keyring is read or written. The older EnclaveNotSupportedError (code 'ENCLAVE_NOT_SUPPORTED', root @noy-db/hub) is deprecated and goes at a later breaking cut; the conformance kit accepts both codes, so a fork that still throws it keeps passing, but new code throws CapsuleNotSupportedError.

Two hard rules:

  1. Never a mix. A group either fully works or fully refuses. A fork whose deriveSealedFieldKey refuses but whose deriveSealedFieldKeyFromCek silently succeeds is non-conformant — see the conformance kit below, which exists specifically to catch this.
  2. The core groups must never throw it. Crypto ops, key lifecycle, the record codec, the envelope-body helpers, and tombstone semantics are unconditional — every fork must implement them for real. CapsuleNotSupportedError is a lever for the four optional groups only, never a way to skip the core contract.

A service that receives CapsuleNotSupportedError (or the deprecated EnclaveNotSupportedError) must fail closed — treat it as "this operation is refused," never as "no protection was necessary." Silently proceeding as if the optional group succeeded would be a data-protection regression, not a graceful degradation.

Running the conformance kit

test-harnesses/enclave-conformance/ in the noy-db source tree is the contract as an executable spec — the enclave equivalent of test-harnesses/adapter-conformance. It ships with the source a fork starts from (it is not a published npm package), and it shape-checks the enclave module structurally — a fork never has to import noy-db's types. Point it at any enclave module (the reference one, or a fork's):

import { runEnclaveConformance } from '@noy-db/ports/capsule'
import * as enclave from './my-fork/capsule/enclave-aes/index.js'

runEnclaveConformance(enclave, {
  supports: { sealing: true, deterministic: true, perRecordKeys: false, classify: false },
})

Set each supports.* flag to whether your enclave implements that optional group. The kit then verifies, in one pass:

  • envelope body round-trip, in both encrypted and plaintext modes (write → open → identical JSON; protocol header untouched),
  • body-helper discriminants (hasPerRecordKey reflects _cek presence; envelopeBodyForHash produces the canonical, key-order-independent body string),
  • tombstone semantics (buildTombstone → isTombstone true on an encrypted collection; live envelopes false; always false in plaintext mode),
  • key lifecycle coherence (deriveKey → wrapKey → unwrapKey round trip recovers a usable key),
  • group-consistency for each optional group — every function in the group works, or every function refuses via the 'ENCLAVE_NOT_SUPPORTED' code; a mixed group fails the suite (see assertGroupRefuses in test-harnesses/enclave-conformance/src/index.ts, and self-test.test.ts, which proves the checker itself catches a deliberately mixed stub),
  • determinism where claimed (encryptDeterministic is stable for the same input),
  • the classify primitives where claimed (digest determinism and salt separation, constant-time tag comparison, k-of-n bounds, AAD splice rejection, per-field/per-collection blind-index separation),
  • no protected-body-field leaks in helper outputs, and
  • known-answer test vectors decrypt correctly (plus, when supports.sealing, the sealed-slot vector decrypts under deriveSealedFieldKey) — useful if your fork claims wire-compatibility with noy-db's own codec.

4 documented coverage omissions: resolveStableCek, rewrapBodyToDek, findByDet, and queryByDet take a collection-shaped context object (StableCekDeps / DeterministicContext) rather than the plain key/envelope arguments a generic kit can construct portably. The kit exercises their lower-level primitives instead (wrapCek/unwrapCek, encryptDeterministic/decryptDeterministic) — a fork's implementation of those four functions is not independently conformance-checked today.

noy-db runs the kit in CI against its own reference enclave (test-harnesses/enclave-conformance/src/conformance.test.ts), proving the kit against a known-good implementation before any fork relies on it.

Security expectations

A fork's enclave inherits these expectations from the reference implementation — they are the contract's security floor, not implementation details a fork is free to relax:

  • Fail closed. A refusal on an optional group must stop the operation, not silently downgrade to "unprotected." Zero npm crypto dependencies (crypto.subtle only) and no Node-only imports (capsule/enclave-aes/** must stay portable to browser/Worker/Deno/Bun) are both mechanically enforced for the reference enclave (the no-crypto-deps and hub-portable architecture checks) and are strongly recommended defaults for a fork.
  • Sealed non-residency. Sealed fields (_sealed[field]) must never be materialized into the plain working set by a barrel helper — no helper returns key material, and openEnvelopeJson/writeEnvelopeBody operate only on the fields they're contracted to touch.
  • forget() RAM-scrub. vault.forget() (GDPR crypto-shred) drops the wrapped keys that make a subject's data recoverable; live in-memory caches (DEKs, vault instance, active tier) are scrubbed as part of the same call. A fork's key-lifecycle functions must preserve this: once a key is dropped, nothing in the enclave should be able to reconstruct it from residual state. (Scope note: on an encrypted vault, forget() is idempotent — a second call for the same subject shreds nothing and does not throw. On an unencrypted collection, isTombstone() returns false by design — nothing is ever recognized as a tombstone — so the shred-once short-circuit only exists in encrypted mode; plaintext mode is not zero-knowledge to begin with. A fork's tombstone predicate must preserve the encrypted-mode contract.)

The body-access ratchet — enclave-body-only

The envelope splits into the protocol header (above) and the protected body, but when the enclave-body-only architecture check was introduced, its scan found 337 direct body-field accesses across 53 non-enclave files already in the tree — too many to migrate in one pass. The check is therefore a ratchet, not a hard ban: each offending file is grandfathered in the PRE_EXISTING_BODY_ACCESS map in scripts/check-architecture.mjs at its scanned count. The rule is exact equality — a file's actual count must always match its grandfathered entry; raising it fails CI, lowering it without updating the map also fails CI (the map has to be edited to bank a real reduction). The map reaching empty is the migration's finish line.

Current remaining count: 268 direct accesses across 52 files (down from the original 337 across 53) — the counts shrink as grandfathered call sites move onto the 4 body helpers.

This ratchet is a fork's friend, not its problem: every grandfathered site that shrinks is a call site that stopped assuming the envelope body's literal shape and started going through openEnvelopeJson / writeEnvelopeBody / hasPerRecordKey / envelopeBodyForHash instead — one fewer place a fork's own enclave has to structurally match the reference implementation's _iv/_data/_cek/_sealed layout. A fork doesn't need to wait for the ratchet to reach zero to fork today (the barrel contract is what's frozen, not the migration's completion), but a smaller grandfather map means less code implicitly assumes noy-db's specific envelope shape.

See also

  • enclave — the encryption core a fork must structurally match
  • kernel — the always-on foundation a fork reimplements
  • ports — the published seams a fork binds instead of hub internals