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 |
| 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 |
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).