Forking the enclave
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 theCapsulePrimitivessurface thatmakeCapsule()consumes (below).classify/— the verify-without-reveal primitives (value digests, blind-index tags, constant-time compare, k-of-n evaluation) consumed by theclassifiedservice.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'senclave-barrel-onlycheck 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 itswith<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 (forto-*) 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(= CryptoKeyin the reference enclave). The opaque handle every barrel-facing signature traffics in. A fork redefines the alias to its own representation (type EnclaveKey = nullfor a keyless design) and the spine type-checks against that keyless world at fork-compile time — outside the enclave,EnclaveKeyis never constructed, inspected, or serialized, only passed between barrel calls. - Crypto ops —
encrypt/decrypt(+Bytes,BytesWithAAD,Deterministicvariants),sha256Hex,hmacSha256Hex. - Key lifecycle —
deriveKey/deriveSecretKey(PBKDF2-SHA256 → AES-KW / AES-GCM; usage typed bySecretKeyUsage),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 (+ theSealedShredSlottype). - Sealing —
sealRecordToHost/revokeSealedRecord/rotateRecordCek,SealingContext,SEALED_CEK_NS. Sealing grants anat-*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) andderiveClassifyIndexKey,deriveClassifyIndexSalt,mintBidxTag,computeBidxTarget(blind-index primitives). The verify/match orchestration around them lives in theclassifiedservice 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/_sealedfor 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_cekitself.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:
- Never a mix. A group either fully works or fully refuses. A fork
whose
deriveSealedFieldKeyrefuses but whosederiveSealedFieldKeyFromCeksilently succeeds is non-conformant — see the conformance kit below, which exists specifically to catch this. - 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.
CapsuleNotSupportedErroris 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 (
hasPerRecordKeyreflects_cekpresence;envelopeBodyForHashproduces the canonical, key-order-independent body string), - tombstone semantics (
buildTombstone→isTombstonetrue on an encrypted collection; live envelopes false; always false in plaintext mode), - key lifecycle coherence (
deriveKey→wrapKey→unwrapKeyround 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 (seeassertGroupRefusesintest-harnesses/enclave-conformance/src/index.ts, andself-test.test.ts, which proves the checker itself catches a deliberately mixed stub), - determinism where claimed (
encryptDeterministicis 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 underderiveSealedFieldKey) — 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.subtleonly) and no Node-only imports (capsule/enclave-aes/**must stay portable to browser/Worker/Deno/Bun) are both mechanically enforced for the reference enclave (theno-crypto-depsandhub-portablearchitecture 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, andopenEnvelopeJson/writeEnvelopeBodyoperate 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()returnsfalseby 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.