Eformogi
The Record: protocol draft 0.1 (CC BY 4.0; verifier Apache-2.0)

04. Keys and custody

Draft 0.1. Reference implementation: shared/ed25519.js, shared/familySigning.js, shared/vaultEnvelope.js, frontend/src/services/webCrypto.js, frontend/src/services/vaultCloudSync.js, frontend/src/services/vaultEnvelopeClient.js, frontend/src/services/vaultKeyCustody.js, api/vault-key.js, frontend/src/utils/receiptSignature.js, api/verify-receipt.js (loadIssuerSignature), supabase/SIGNED_RECEIPTS_V1_PHASE_A.sql, supabase/V42_ZK_FAMILY_KEY_WRAPS.sql, docs/H1_STAGE2_ZERO_KNOWLEDGE_ADR.md. Vectors v09, v10, v11.

4.1 Two kinds of key, three signed artifacts, three contexts

keyidentifiersignscontext prefix
organization key (one)did:web:eformogi.com#key-1signed tree heads (chapter 05), witness countersignatures (chapter 03), Verifiable Credentials (chapter 07)eformogi-sth-v1:, eformogi-witness-countersig-v1:, and the eddsa-jcs-2022 sign input
family key (one per family account)did:key:z6Mk...the family's own receipts (tr_ cr_ ws_ wn_ wl_)eformogi-receipt-sig-v1:

Every context prefix is distinct and frozen (gate-tested). A signature MUST be verified with its context prepended; a signature over the bare message MUST NOT verify (vector v11). This is what stops a receipt signature from being replayed as a tree head, or a countersignature as a family signature.

All keys are Ed25519. Public keys are exchanged as JWK {kty: "OKP", crv: "Ed25519", x}. Signatures are 64 raw bytes rendered as 'z' + base58btc(bytes).

4.2 The organization key

4.3 The family key

Minted on the family's first publish after family signing is enabled (EFG_SIGNED_RECEIPTS=1); flag off means no keys are read or written and receipts publish unsigned.

field (table family_signing_keys)typemeaning
user_iduuidthe family account; deleting the account deletes the private key (cascade) while the public binding survives so issued receipts stay verifiable
key_iddid:key:z6Mk...multicodec 0xed 0x01 + 32-byte public key, base58btc, multibase z
public_jwkJWK
private_wrappedvk1: envelopethe 32-byte seed, AES-256-GCM wrapped under the server-held VAULT_KEK (section 4.6): escrow
kek_versionint1

Signing (signPublishedReceipt): after a receipt row is inserted and its leaf appended to the log, the implementation signs 'eformogi-receipt-sig-v1:' + <leaf payload> (chapter 02, section 2.5) with the family key and stores issuer_sig, issuer_key_id, sig_alg: "Ed25519" on the receipt row. The signature binds the same bytes the log anchors, so signer and log can never disagree about what was signed. The signature columns are outside every hash domain.

Rules (MUST):

4.4 The kb_ key binding

Minting a family key appends a public receipt establishing it. The payload is frozen (vector v09):

{"established_at":"2026-07-17T00:00:00.000Z","key_id":"did:key:z6MkiPpc1FZaVEji99SsAa5GyfGprgJVgfpQCVcoZQyBxv2X","public_jwk":{"crv":"Ed25519","kty":"OKP","x":"Oo09J95ausJIDH4nnNNy6wfKgveRbGgDsX9V3Y0xyMQ"}}
fieldtypemeaning
established_atISO msthe mint time; also the leaf issued_at
key_iddid:keyMUST equal the did:key derived from public_jwk.x
public_jwk{crv, kty, x} onlyreduced to the three OKP members

kb_ id = kb_ + first 12 hex of sha256(canonical payload); the vector above yields kb_b5ed898815ac. The row lives in key_binding_receipts (no PII by construction), resolves at /verify/kb_<id> with custody: "escrowed_by_eformogi", and is logged like any other receipt, so a swapped or backdated key would be publicly visible. kb_ receipts are never revoked.

4.5 What a receiver can check in the browser

The verify payload for a signed receipt carries (issuer_signature):

fieldmeaning
sigmultibase signature over the context-prefixed leaf
key_idthe family did:key
algEd25519
public_jwkfrom the kb_ binding
kb_receipt_id, key_established_atthe binding's own verify page and timestamp
custodyescrowed_by_eformogi for every family (the signing key)
vault_custody{holder: "family", since} only when that family completed the vault cutover (section 4.6); otherwise null

The receiver's browser, not the operator, does the math (receiptSignature.js): it fetches the exact leaf bytes from /api/receipt-proof (served even while the leaf is still pending a tree head), falls back to reconstructing the leaf locally for ws_/wn_/tr_ (whose payload issued_at is byte-exact with the leaf), imports public_jwk with WebCrypto, and verifies. Verdicts MUST be rendered honestly:

verdictmeaningpage copy
validthe browser verified the signature"Signed by this family's records-office key" plus the escrow caveat
invalidleaf bytes were available and the math failedloud warning
unsupportedthis browser's WebCrypto lacks Ed25519never rendered as a pass
unavailableleaf bytes could not be obtained (log entry missing and reconstruction did not match; cr_/wl_ leaves carry a publish-time timestamp the payload does not echo)no local check claimed

An unsigned receipt renders: "Not signed with a family key. This receipt verifies by integrity alone."

4.6 Vault custody: escrow versus family-held

The published receipts are public by design. The private vault behind them is a separate custody question with three layers, each with its own wire prefix:

layerkeyprefixcustodystatus
on-device person records (IndexedDB)random non-extractable AES-256-GCM CryptoKey in a dedicated keystorewcv1:the devicebuilt (signed-in users; a guest's sole copy stays plaintext because the cloud copy is the recovery path)
cloud vault rows (vault_data.data)per-family random 256-bit DEK, AES-256-GCMvev1:the DEKbuilt ("encrypted at rest")
the DEK's wrapStage 1: server KEK (VAULT_KEK)vk1:the operator (escrow)built, default for every family
the DEK's wrap after cutoverfamily passkey (WebAuthn PRF, HKDF) and a one-time recovery codezkw1:, zkr1: + verify tagthe familybuilt, flag VITE_ZK_CUSTODY, per-family opt-in ceremony

The Stage-1 to Stage-2 move re-wraps the DEK; the vault ciphertext never changes (no second data migration). The per-family cutover (POST /api/vault-key, action cutover) MUST refuse unless all of: an explicit confirm string, a passkey wrap on file, a recovery wrap on file, and a verified readback of the recovery code. On cutover the server overwrites its own wrap with the tombstone zk-removed and sets server_wrap_removed_at; from then on GET /api/vault-key returns zk: true and blobs the server cannot open, and the cloud sync HOLDS writes (never plaintext) while the family's key is locked.

Rotation after cutover (rotate-wraps) replaces both wraps and is authorized only by a timing-safe match of the current recovery code's one-way verify tag; the tombstone is never touched. Recovery is the recovery code, unwrapped on device; the code never leaves the browser. A family that loses both its passkey and its recovery code has an unrecoverable vault, by anyone, including the operator, and the security page says so.

4.7 The honest claims, exactly

claimtrue fornot true for
"Your browser checked this signature against the family's public key"any receipt with a valid verdictunsigned or unavailable receipts
"The family's signing key is held in escrow by the operator on the family's behalf"every family, today(no exception exists yet)
"This family holds the only keys to its vault; the operator cannot read it"families with server_wrap_removed_at set (at the time of this draft, a single family)every other family, whose vault is encrypted at rest under a key the operator still holds
"Zero-knowledge" as a global product claimnobody, and this specification MUST NOT be quoted as making it
"No plaintext ever reaches the operator's servers"nobody: document extraction is a transient server-side AI processing path for every family, including after custody cutover; an uploaded document is processed for field extraction and not retained (the declared exception in docs/H1_STAGE2_ZERO_KNOWLEDGE_ADR.md, disclosed on frontend/src/pages/SecurityPage.jsx)

The verify page states the per-family vault fact only when the server proved it, keeps the escrow caveat next to it, and says "when key custody moves to families, this page will say so."

4.8 What this does not prove

A valid family signature proves that the escrowed key bound to this family's account signed these leaf bytes when the operator's infrastructure published them. Because custody is escrow, it does not prove the family personally authorized that exact signing operation; it proves the operator signed under the family's identity as part of a publish the family initiated. A kb_ proves when a key was publicly established, not who controls it. The organization key proves the operator's infrastructure signed, not that the operator vouches for any claim inside the receipt.