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
| key | identifier | signs | context prefix |
|---|---|---|---|
| organization key (one) | did:web:eformogi.com#key-1 | signed 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
- Public JWK
x = Oo09J95ausJIDH4nnNNy6wfKgveRbGgDsX9V3Y0xyMQ, served in the DID document at/.well-known/did.jsonas verification methoddid:web:eformogi.com#key-1(typeJsonWebKey2020) and embedded in the reference verifier. - Per the setup runbook (
docs/ANCHORED_LEDGER_SETUP.md, step 1) and the source comment inshared/ed25519.js, the private half lives only in the deployment environment (VC_SIGNING_PRIVATE_KEY) and the founder's password manager, with the key ceremony recorded as performed on 2026-07-05. This draft reports that record; it has not been independently verified. - Drift guard. Before every signing operation the implementation derives the public key from the private key and refuses to sign unless it equals the published
x(loadPrivateKey). A rotated or wrong private key can never silently sign under the published identity. - Rotation: planned. There is no second published key and no rotation statement format yet. The anchored ledger (chapter 05) means pre-rotation tree heads stay verifiable against the old key and a forked history is detectable from third-party git timestamps; the rotation procedure itself (new
#key-2, signed rotation statement in the anchor repository) is unbuilt.
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) | type | meaning |
|---|---|---|
user_id | uuid | the family account; deleting the account deletes the private key (cascade) while the public binding survives so issued receipts stay verifiable |
key_id | did:key:z6Mk... | multicodec 0xed 0x01 + 32-byte public key, base58btc, multibase z |
public_jwk | JWK | |
private_wrapped | vk1: envelope | the 32-byte seed, AES-256-GCM wrapped under the server-held VAULT_KEK (section 4.6): escrow |
kek_version | int | 1 |
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):
- No self-heal. If a stored key fails to unwrap (KEK drift, corruption), the implementation publishes unsigned and alerts; it MUST NOT mint a replacement over the old identity. A silently swapped signing key is an identity break, strictly worse than a missed signature. Recovery is a human decision.
- Best effort, never blocking. A signing failure MUST NOT fail, slow, or reshape a family's publish.
- Guest publishes are never signed (no account, no key).
publish-attestationis deliberately not family-signed; the witness model is chapter 03.- Rotation: planned (gap: no family signing-key rotation path; a new key would need a new
kb_and a signed link from the old key). Recovery: because the seed is escrowed the family cannot lose it; that is the trade escrow makes, stated in section 4.7.
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"}}
| field | type | meaning |
|---|---|---|
established_at | ISO ms | the mint time; also the leaf issued_at |
key_id | did:key | MUST equal the did:key derived from public_jwk.x |
public_jwk | {crv, kty, x} only | reduced 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):
| field | meaning |
|---|---|
sig | multibase signature over the context-prefixed leaf |
key_id | the family did:key |
alg | Ed25519 |
public_jwk | from the kb_ binding |
kb_receipt_id, key_established_at | the binding's own verify page and timestamp |
custody | escrowed_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:
| verdict | meaning | page copy |
|---|---|---|
valid | the browser verified the signature | "Signed by this family's records-office key" plus the escrow caveat |
invalid | leaf bytes were available and the math failed | loud warning |
unsupported | this browser's WebCrypto lacks Ed25519 | never rendered as a pass |
unavailable | leaf 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:
| layer | key | prefix | custody | status |
|---|---|---|---|---|
| on-device person records (IndexedDB) | random non-extractable AES-256-GCM CryptoKey in a dedicated keystore | wcv1: | the device | built (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-GCM | vev1: | the DEK | built ("encrypted at rest") |
| the DEK's wrap | Stage 1: server KEK (VAULT_KEK) | vk1: | the operator (escrow) | built, default for every family |
| the DEK's wrap after cutover | family passkey (WebAuthn PRF, HKDF) and a one-time recovery code | zkw1:, zkr1: + verify tag | the family | built, 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
| claim | true for | not true for |
|---|---|---|
| "Your browser checked this signature against the family's public key" | any receipt with a valid verdict | unsigned 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 claim | nobody, 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.