09. Threat model
Draft 0.1. Status vocabulary: built (in the reference implementation and pinned by a trust-gate test where one is named), partial (a control exists but does not close the class), planned (named, not built). Every row cites the implementing file. Red-team history is in TRUST_REPORT.md.
9.1 Assets and adversaries
Assets: a learner's record (private content on device and in the encrypted vault), the public receipts and their log, the keys, and the trust a receiver places in a verify page. Adversaries: an issuer who inflates, a stranger who fabricates witnesses, a forger who mints receipts under someone else's name, the operator itself (or someone with its database access) rewriting history, anyone who steals or loses a key, and the law where it binds a records office holding minors' data. The grading rule the operator uses: impact if breached times whether the door is open now, not current data volume; a records office holding minors' data is reputation-fatal on one breach at any scale.
9.2 Issuer inflation
The family is the issuer. It can title a course anything, grade it anything, and select any tier chip.
| control | status | file |
|---|---|---|
Tier chips are rendered as the issuer's description; mentor and receiver chips are muted with "issuer-declared" unless the ledger corroborates them (a co-signature on file; a matched institutional domain) | built | api/verify-receipt.js (course_signature_rollup), frontend/src/pages/VerifyReceiptPage.jsx (TranscriptTierBlock) |
Course rows are individually addressable (cr_) and can carry an outside witness's signed_grade; disagreement is rendered beside the family's grade, never hidden | built | api/publish-course-receipts.js, api/publish-attestation.js, tests/test-course-attestations.js |
"Source-verified" requires a signature from a matched institutional domain; since no mailbox proof exists, domain_matched is never granted, so every receiver chip renders as issuer-declared | partial (honest by construction, empty in practice) | api/publish-attestation.js (signer_verification = 'self_declared'), tests/test-attestation-no-unverified-domain.js |
| The verify page states that a self-issued transcript's hash does not prove third-party evaluation | built | VerifyReceiptPage.jsx honesty blocks, chapter 06 section 6.6 |
| Sums, never averages; no GPA rank surfaces that would reward inflation | built | tests/test-sums-never-averages.js, tests/test-roots-mechanisms.js |
| Witness identity levels 1 to 3 so a co-signature can carry weight | built (2026-09-07) | shared/witnessIdentity.js, api/publish-attestation.js, api/admin-witness-identity.js, tests/test-witness-identity.js, chapter 03, section 3.9 |
| Organization issuers accountable per learner | planned (issuer rail) | chapter 01, section 1.7 |
9.3 Fabricated witnesses
Attestations are minted by a public endpoint by design (a witness must not need an account).
| control | status | file |
|---|---|---|
A course-row attestation requires a live, family-minted invitation token bound to that exact cr_ (not revoked, under 60 days) | built (ruling 2026-09-04) | api/publish-attestation.js, api/request-attestation.js, api/resolve-witness-token.js |
| The target receipt must exist; orphan attestations cannot inflate "witnessed by N" | built | api/publish-attestation.js |
| No institutional-domain credibility from a claimed email address | built (red-team 2026-08-13, #4) | tests/test-attestation-no-unverified-domain.js |
| Unbound signers render but never move the dissent gauge or wall badges; anonymous signers collapse into one bucket | built (red-team 2026-09-02, #1) | api/verify-receipt.js (boundSigner), tests/test-attestation-gauge-integrity.js |
| The issuer of the target may hide a poisoned attestation; the count of hidden rows is disclosed | built (red-team 2026-09-02, #1) | api/revoke-family-receipt.js, tests/test-attestation-tombstone.js |
| Self-attestation on a course row is refused when the signer's email hash equals the issuer's; parent-as-witness on a work sample is labeled | built | api/publish-attestation.js (pin #9), tests/test-witness-phase-c.js |
| Free text is firewalled for PII and injection before hashing or storage | built | shared/firewall.js, policies/firewall.md, tests/test-firewall.js |
| Rate limits: 10 attestations per minute per IP; disputes 3 per minute | built | shared/rateLimiterDurable.js, tests/test-ratelimit-coverage.js |
| Organization countersignature timestamps the ceremony under its own context | built, flag-gated (EFG_SIGNED_RECEIPTS) | shared/witnessCountersig.js |
| Dispute rail with durable record and human review; nothing public changes on filing | built | api/dispute-attestation.js, tests/test-attestation-dispute.js |
| Mailbox proof (level 1), licensed-role lookups, identity verification, reputation | planned (W7) | chapter 03, section 3.9 |
| Witness-initiated revocation of their own attestation | planned | chapter 03, section 3.7 |
9.4 Receipt forgery
| control | status | file |
|---|---|---|
Publishing a transcript requires a signed-in account; issuer_user is pinned from the verified token, never the body | built | api/publish-transcript.js |
Course rows claiming a tr_receipt_id must be posted by that transcript's owner | built (red-team 2026-08-13, #3) | api/publish-course-receipts.js, tests/test-course-receipt-ownership.js |
| Every id is re-derived server-side from the canonical hash domain; a client-claimed id that does not match is refused | built | api/publish-course-receipts.js, api/publish-attestation.js, api/publish-wall.js |
| Same id with a different hash is refused (409); the family must re-issue to change content | built | all api/publish-*.js |
| Canonicalization parity between client and server is gate-tested | built | tests/test-canonicalize-parity.js, tests/test-merkle-log.js |
| Canonicalization is deterministic and key-order independent, every copy agrees, and any single byte changes the hash, proved on generated input rather than chosen fixtures | built (2026-09-14, property-based) | tests/test-property-canonicalization.js, section 9.10 |
A proof relabeled with another receipt's id, or with a tree_size that is not the signed head's, is refused by the published verifier | built (2026-09-14, vector v23) | spec/verifier/verify-offline.js, spec/verifier/vectors/v23-proof-relabeled-receipt-id.json, tests/test-property-verifier.js |
| Family Ed25519 signature binds the exact anchored leaf; verified in the receiver's browser | built, flag-gated (EFG_SIGNED_RECEIPTS) | shared/familySigning.js, frontend/src/utils/receiptSignature.js, tests/test-family-signing.js |
| Verify payloads never carry identity beyond the printed surface; output keys are snapshot-tested | built | api/verify-receipt.js, tests/test-verify-receipt-shape.js |
| Anti-enumeration: 10 verifies per minute per anonymous IP; keyed callers on a hashed-key bucket with an IP backstop | built | api/verify-receipt.js |
| Security events on denied forgeries feed a daily sentinel | built | api/_lib/securityEvents.js, api/cron-security-sentinel.js, tests/test-security-sentinel.js |
Guest-issued receipts are unsigned, unrevocable, and never reach the public ledger from publish-transcript | built (disclosed limitation) | api/publish-transcript.js (401 for guests) |
9.5 Platform capture
The operator, or anyone with its database, is an adversary the design must survive.
| control | status | file |
|---|---|---|
| The log is append-only at the database: UPDATE, DELETE, and TRUNCATE are refused by trigger; tree-head rows are immutable except a single null-to-value anchor write | built | supabase/V34_RECEIPT_LOG.sql |
| Domain-separated hashing (0x00 leaf, 0x01 node, 0x02 chain) defeats second-preimage forgery of inclusion proofs | built | shared/merkleLog.js, tests/test-merkle-log.js |
| The daily signer recomputes the root at the latest head and the latest externally anchored head and refuses to sign over divergence | built | api/cron-tree-head.js (self-audit), tests/test-tree-head-cron.js |
| Every newly covered leaf is re-validated (grammar, canonical round trip, hash, chain) before signing | built | api/cron-tree-head.js (validateLeaves) |
| Heads and non-PII leaf payloads are anchored in a third-party git history whose timestamps the operator cannot rewrite | built (8 of 8 heads anchored) | api/cron-tree-head.js (anchorToGitHub, sweepUnanchoredHeads), TRUST_REPORT.md |
| A receiver verifies inclusion offline against the published key with zero trust in the server | built | spec/verifier/verify-offline.js, scripts/verify-receipt-offline.js, tests/test-receipt-proof-endpoint.js |
| Every tampering of a proof (leaf id, hash, or time; leaf hash; audit path; index; head fields; head signature; family signature; swapped key; context-free signature) is refused by the published verifier, in process and at the command line, on logs and keys generated for the run | built (2026-09-14, property-based) | tests/test-property-verifier.js, section 9.10 |
Key establishment is public (kb_); a swapped or backdated family key is visible in the log; keys never self-heal | built | shared/familySigning.js, tests/test-family-signing.js |
| The published organization key is the same object the signer's drift guard checks; the DID document cannot drift from the signing key | built | shared/ed25519.js, api/did.js, tests/test-ed25519-issuance.js |
| A family can export its full record and leave | built | api/data-export.js (client-side decrypt), settings export/import round trip |
| Revocation is complete: purging forwarded institutional copies and storage blobs, not just the row | built | shared/forwardingPurge.js, shared/consentStoragePurge.js, tests/test-rule-1-revocation-completeness.js, tests/test-revocation-storage-purge.js |
| Server-side plaintext readers of the vault ratcheted from seven to one allowed ciphertext passthrough | built | tests/test-vault-plaintext-ratchet.js, docs/H1_STAGE2_ZERO_KNOWLEDGE_ADR.md |
| Consistency proofs between heads; a second, independent anchor (OpenTimestamps) | planned | chapter 05, section 5.6 |
| An organization-key rotation statement format in the anchor repository | planned | chapter 04, section 4.2 |
| Independent monitors running the daily audit outside the operator | planned (the anchor files make it possible today; nobody runs one yet) |
9.6 Key loss and compromise
| key | loss | compromise | status and file |
|---|---|---|---|
| organization key | signing stops; nothing already anchored is affected | forged heads and countersignatures are detectable against third-party anchor timestamps; recovery is rotation plus a signed statement | drift guard built (shared/ed25519.js); rotation planned |
| family signing key | cannot be lost by the family (escrowed); a key that fails to unwrap publishes unsigned and alerts, never re-mints | an attacker with the server KEK could sign under any family; this is the cost of escrow and is disclosed on every verify page | built and disclosed (shared/familySigning.js, custody: escrowed_by_eformogi); family-held signing keys planned |
| vault DEK (Stage 1) | none (server wrap) | an attacker with database plus VAULT_KEK reads vaults | built; disclosed as "encrypted at rest" (shared/vaultEnvelope.js) |
| vault DEK (per-family cutover) | lost passkey plus lost recovery code is unrecoverable by anyone | the operator cannot read the vault; a stolen device with an unlocked session can | built, flag VITE_ZK_CUSTODY, opt-in ceremony (frontend/src/services/vaultKeyCustody.js, api/vault-key.js, tests/test-zk-key-custody.js) |
| local device key | the cloud copy is the recovery path (guests keep plaintext for this reason) | a non-extractable CryptoKey cannot be dumped from IndexedDB data | built (frontend/src/services/webCrypto.js, tests/test-vault-local-encryption.js) |
| witness token | expires at 60 days; single use; family can revoke | a forwarded link is honored and the mismatch is recorded, never blocked | built (api/resolve-witness-token.js, api/revoke-witness-request.js) |
| organization API keys | hashed at rest; shown once | header-only; never in query strings | built (shared/orgApiAuth.js, tests/test-legacy-key-hashing.js, tests/test-no-query-secrets.js) |
9.7 Legal
US-first. GDPR is out of scope for this draft: the erasure and export mechanisms exist, but no EU representative, DPA, or transfer mechanism is claimed.
| regime | how the Record meets it | status | file |
|---|---|---|---|
| FERPA | The family, not a school, is the records office of record, so the operator does not hold "education records" of an educational agency on the family side. Where an institution receives family data, it does so by explicit field-level consent with a receipt, and revocation purges every copy, including forwarded ones. | built controls; formal opinion planned | api/institutional/consent.js, api/consent-receipt.js, shared/forwardingPurge.js, tests/test-rule-1-revocation-completeness.js |
| COPPA | The account holder is a parent or guardian; children do not hold accounts. Learner-authored surfaces (wn_, wl_) publish only a family-approved excerpt or curated list, carry no view tracking, and are never witnessed. No behavioral advertising exists. Parent-consent mechanics for any direct collection from a child under 13 are flagged for founder and counsel review. | partial; attorney review planned | api/publish-weekly-note.js, api/publish-wall.js, api/counselor/add-student.js (FOUNDER-REVIEW note), api/_lib/send-email.js |
| NY Education Law 2-d | Applies to educational agencies and their third-party contractors. The family-direct Record is neither. The counselor caseload (an institutional surface) is where 2-d obligations could attach if a school district became a contracting party; no district contract exists. | partial (surface documented; no contract; counsel review planned) | frontend/src/pages/PrivacyPage.jsx, api/counselor/* |
| Deletion and pseudonymization | Account deletion hard-deletes vault, links, credentials, and non-consent audit rows; consent artifacts are pseudonymized (user id nulled, salted hash, identifiers stripped) so "did this institution ever have permission?" stays answerable without a person attached. Log leaves are non-PII and survive. | built | api/delete-account.js, supabase/V34_RECEIPT_LOG.sql |
| Minors' data breach (the binary risk) | RLS enabled with owner-scoped or deny-all policies on every family-data table, verified empirically; ownership enforced in code on service-role handlers; a red-team sweep cadence with a permanent antibody test per confirmed finding | built (Aug 14 verification); systemic pattern partial (service-role default) | shared/redTeamCadence.js (LAST_SWEEP = 2026-09-02), .claude/skills/red-team-sweep/, TRUST_REPORT.md |
| Honest claims (UDAP exposure) | "Encrypted at rest" not "we cannot read it"; escrow stated; per-family custody stated only from server proof; "verifiable" never "accepted" | built and gate-pinned | tests/test-zk-key-custody.js (section 9), frontend/src/pages/SovereigntyPage.jsx, frontend/src/pages/SecurityPage.jsx |
9.8 Privacy leakage through the protocol itself
| vector | control | status |
|---|---|---|
| log leaves | four non-PII fields by construction; the signer refuses any other shape | built |
| witness email | hashed on the attestation; plaintext only on the family's own request row | built and disclosed |
| verify-view tracking | fires on tr_ and ws_ only (the family is notified someone verified); never on wn_, wl_, cr_, kb_; self-views excluded | built |
| wall badges | counts of distinct signers only; never names or hashes | built |
| receipt-proof and tree-heads | public, cached, no PII; rate-limited against enumeration | built |
| anchor repository | non-PII by construction; receipt rows stay deletable while anchors survive | built |
| document extraction | a transient server-side AI processing path for every family, including after custody cutover: the uploaded document is processed for field extraction and not retained; disclosed on the security page and declared as the one exception in the zero-knowledge ADR | built and disclosed (docs/H1_STAGE2_ZERO_KNOWLEDGE_ADR.md, frontend/src/pages/SecurityPage.jsx); on-device extraction planned as an opt-in |
9.9 What this does not prove
A threat model is a list of doors and locks, not a proof that no door was missed. The two red-team sweeps to date found real holes in the live system (11 of 21 raised on 2026-08-13; 20 of 25 raised on 2026-09-02) and closed them with antibodies; the next sweep is due on or about 2026-10-02. No external firm has reviewed this system yet (TRUST_REPORT.md). Section 9.10 records what the property-based tests added on 2026-09-14 cover, and the gaps they exposed.
9.10 Property-based coverage, and the gaps it exposed
Added 2026-09-14. Tests: tests/test-property-canonicalization.js, tests/test-property-verifier.js, both in the trust gate; fast-check 4.10.0; a failure prints a seed and FC_SEED=<seed> replays it.
The conformance vectors (chapter 10) prove the algorithms on a few dozen chosen inputs. Property-based tests prove them on inputs nobody chose: each run generates a few thousand values, logs, keys, and proofs, and checks a sentence from the chapters against every one. The two files run against the published verifier (spec/verifier/verify-offline.js, the file a receiver downloads) and the shared server modules it mirrors, with organization and family keys minted for the run and never a live key.
| property | the sentence it proves | chapter |
|---|---|---|
| canonicalize is deterministic and key-order independent at every depth; the bytes parse back to the same content and re-canonicalize to the same bytes; no whitespace outside string literals | 2.3 rules 1 to 3 | 02 |
the verifier, shared/merkleLog.js, and shared/courseReceipt.js canonicalize identically | 2.3 (the copies must not drift) | 02 |
assertJcsSafe accepts every grammar value and refuses every non-integer number and every undefined, wherever it sits | 2.3 rule 5 | 02 |
| any single-byte change to a canonical payload changes its SHA-256 and its leaf hash; any single-character change to a leaf payload does too | 2.4 (identity of content) | 02 |
| the leaf grammar normalizes case and every spelling of an instant to one byte string; every non-loggable prefix is refused, in both copies | 2.5 | 02, 05 |
cr_ derivation agrees between the verifier and the server on random rows, or both refuse; "1" and "1.0" are different receipts | 2.4 | 02 |
base58btc round-trips, leading zeros included; did:key and the frozen kb_ payload agree between the verifier and the server | 4.1, 4.4 | 04 |
| a signature verifies only against its exact bytes, only under its own context prefix, only under its own key; one changed character in the message or the signature is refused; the countersignature context binds the same way | 4.1 | 04, 03 |
| an audit path folds to the root from its leaf at its index; it is refused at any other index, with any changed element, root, or leaf hash, and at any other length | 5.4 | 05 |
| a signed tree head verifies with exactly its seven fields, ignores a stray unsigned field, and refuses any changed field or signature character | 5.3 | 05 |
| a genuine proof verifies, signed or not, with and without the artifact hash; every one of twenty-one tamperings is refused in process and, on a sample, at the command line with the documented exit codes | 6.2 step 5, 6.4 | 06 |
| a family key established after issuance still verifies, with a note and no failed step | 6.2 step 4 | 06 |
a proof whose receipt_id is present but not a string (an array, a number, null) is refused, and the structured result names the id inside the logged leaf | 6.2 step 5 check 0 | 06 |
the conformance suite matches every expected verdict, through the shell runner and through runVector in process | 10.1 | 10 |
Gaps the properties exposed
| gap | found by | consequence | status |
|---|---|---|---|
The verifier never compared a proof's top-level receipt_id with the id inside the logged leaf. A genuine proof for a harmless receipt, relabeled with the id of a contested one, verified, and the final line printed VERIFIED with the relabeled id. | the proof-tampering property, first run | a receiver who trusted the verifier's last line rather than reading the leaf could be told the wrong receipt was in the log | closed 2026-09-14, tightened 2026-09-15: the verifier checks receipt_id against the leaf and tree_size against the signed head whenever the fields are present, and a value of the wrong type fails the check rather than skipping it (the first fix ran the label check only for a string receipt_id, so an array, a number, or null bypassed it; review caught it); the verifier's last line and the package's result name the id inside the logged leaf, never the label (chapter 06, step 5, check 0); vectors v23 and v24 pin it |
Inclusion verification alone does not pin tree_size. With the RFC 9162 fold, the audit path from a tree of n leaves can fold to that tree's root under a claimed size of n plus one, because the fold cannot tell a leaf hash from an interior hash in the path. | designing the inclusion property (the tree-size mutation had to be moved to the proof level) | none in practice, and none new: the signed head binds root_hash to tree_size, so a claimed size that disagrees with the head fails the head's signature | documented: a conforming verifier MUST check the head's signature and MUST NOT treat the fold alone as inclusion; the reference verifier always runs both, and the property file's header says why it does not assert on tree_size at the fold |
assertJcsSafe accepts integers at or above 1e21, which JSON.stringify renders in exponent form (1e+21), and integers above 2 to the 53rd, which lose precision on parse in JavaScript and print differently in other languages. | the fixed edge cases appended to the canonicalization test | none today: nothing the Record signs carries such a number (version, tree_size, leaf_index are small; credits is a string by rule 5 of chapter 02), so no receipt is affected | open, documented: an implementation SHOULD refuse integers outside the range plus or minus 2 to the 53rd in any value it signs; tightening assertJcsSafe is planned and the test pins today's behavior as a gap, not a pass |
A lone surrogate in a string canonicalizes to an escape sequence (\ud800) because JSON.stringify is well-formed since ES2019, so the canonical bytes are ASCII and deterministic in JavaScript. A port in another language that emits the raw code unit, or refuses it, hashes differently. | the fixed edge cases | none for the reference implementation; a cross-language risk for ports | documented; a vector carrying such a string is a candidate for the next draft, and chapter 02's "no Unicode normalization" note now has this companion |
| The "wrong index is refused" property depends on leaf hashes being distinct. | writing the inclusion property | none: the log is idempotent on (receipt_id, sha256) and both fields are inside every leaf, so two leaves can never be byte-identical | documented as the assumption the property makes |
What the properties do not cover
The online endpoints (rate limits, revocation states, RLS), the family signing path against a real KEK (tests/test-family-signing.js covers it), the browser verifier (frontend/src/utils/receiptSignature.js), consistency proofs between heads (unbuilt, chapter 05, section 5.6), and the credential proof's @context prefix check (chapter 11, section 11.6). A property proves a sentence for the inputs the generator can reach; the fixed edge cases exist for the inputs it cannot.