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

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.

controlstatusfile
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)builtapi/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 hiddenbuiltapi/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-declaredpartial (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 evaluationbuiltVerifyReceiptPage.jsx honesty blocks, chapter 06 section 6.6
Sums, never averages; no GPA rank surfaces that would reward inflationbuilttests/test-sums-never-averages.js, tests/test-roots-mechanisms.js
Witness identity levels 1 to 3 so a co-signature can carry weightbuilt (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 learnerplanned (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).

controlstatusfile
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"builtapi/publish-attestation.js
No institutional-domain credibility from a claimed email addressbuilt (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 bucketbuilt (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 disclosedbuilt (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 labeledbuiltapi/publish-attestation.js (pin #9), tests/test-witness-phase-c.js
Free text is firewalled for PII and injection before hashing or storagebuiltshared/firewall.js, policies/firewall.md, tests/test-firewall.js
Rate limits: 10 attestations per minute per IP; disputes 3 per minutebuiltshared/rateLimiterDurable.js, tests/test-ratelimit-coverage.js
Organization countersignature timestamps the ceremony under its own contextbuilt, flag-gated (EFG_SIGNED_RECEIPTS)shared/witnessCountersig.js
Dispute rail with durable record and human review; nothing public changes on filingbuiltapi/dispute-attestation.js, tests/test-attestation-dispute.js
Mailbox proof (level 1), licensed-role lookups, identity verification, reputationplanned (W7)chapter 03, section 3.9
Witness-initiated revocation of their own attestationplannedchapter 03, section 3.7

9.4 Receipt forgery

controlstatusfile
Publishing a transcript requires a signed-in account; issuer_user is pinned from the verified token, never the bodybuiltapi/publish-transcript.js
Course rows claiming a tr_receipt_id must be posted by that transcript's ownerbuilt (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 refusedbuiltapi/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 contentbuiltall api/publish-*.js
Canonicalization parity between client and server is gate-testedbuilttests/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 fixturesbuilt (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 verifierbuilt (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 browserbuilt, 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-testedbuiltapi/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 backstopbuiltapi/verify-receipt.js
Security events on denied forgeries feed a daily sentinelbuiltapi/_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-transcriptbuilt (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.

controlstatusfile
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 writebuiltsupabase/V34_RECEIPT_LOG.sql
Domain-separated hashing (0x00 leaf, 0x01 node, 0x02 chain) defeats second-preimage forgery of inclusion proofsbuiltshared/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 divergencebuiltapi/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 signingbuiltapi/cron-tree-head.js (validateLeaves)
Heads and non-PII leaf payloads are anchored in a third-party git history whose timestamps the operator cannot rewritebuilt (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 serverbuiltspec/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 runbuilt (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-healbuiltshared/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 keybuiltshared/ed25519.js, api/did.js, tests/test-ed25519-issuance.js
A family can export its full record and leavebuiltapi/data-export.js (client-side decrypt), settings export/import round trip
Revocation is complete: purging forwarded institutional copies and storage blobs, not just the rowbuiltshared/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 passthroughbuilttests/test-vault-plaintext-ratchet.js, docs/H1_STAGE2_ZERO_KNOWLEDGE_ADR.md
Consistency proofs between heads; a second, independent anchor (OpenTimestamps)plannedchapter 05, section 5.6
An organization-key rotation statement format in the anchor repositoryplannedchapter 04, section 4.2
Independent monitors running the daily audit outside the operatorplanned (the anchor files make it possible today; nobody runs one yet)

9.6 Key loss and compromise

keylosscompromisestatus and file
organization keysigning stops; nothing already anchored is affectedforged heads and countersignatures are detectable against third-party anchor timestamps; recovery is rotation plus a signed statementdrift guard built (shared/ed25519.js); rotation planned
family signing keycannot be lost by the family (escrowed); a key that fails to unwrap publishes unsigned and alerts, never re-mintsan attacker with the server KEK could sign under any family; this is the cost of escrow and is disclosed on every verify pagebuilt 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 vaultsbuilt; disclosed as "encrypted at rest" (shared/vaultEnvelope.js)
vault DEK (per-family cutover)lost passkey plus lost recovery code is unrecoverable by anyonethe operator cannot read the vault; a stolen device with an unlocked session canbuilt, flag VITE_ZK_CUSTODY, opt-in ceremony (frontend/src/services/vaultKeyCustody.js, api/vault-key.js, tests/test-zk-key-custody.js)
local device keythe cloud copy is the recovery path (guests keep plaintext for this reason)a non-extractable CryptoKey cannot be dumped from IndexedDB databuilt (frontend/src/services/webCrypto.js, tests/test-vault-local-encryption.js)
witness tokenexpires at 60 days; single use; family can revokea forwarded link is honored and the mismatch is recorded, never blockedbuilt (api/resolve-witness-token.js, api/revoke-witness-request.js)
organization API keyshashed at rest; shown onceheader-only; never in query stringsbuilt (shared/orgApiAuth.js, tests/test-legacy-key-hashing.js, tests/test-no-query-secrets.js)

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.

regimehow the Record meets itstatusfile
FERPAThe 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 plannedapi/institutional/consent.js, api/consent-receipt.js, shared/forwardingPurge.js, tests/test-rule-1-revocation-completeness.js
COPPAThe 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 plannedapi/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-dApplies 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 pseudonymizationAccount 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.builtapi/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 findingbuilt (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-pinnedtests/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

vectorcontrolstatus
log leavesfour non-PII fields by construction; the signer refuses any other shapebuilt
witness emailhashed on the attestation; plaintext only on the family's own request rowbuilt and disclosed
verify-view trackingfires on tr_ and ws_ only (the family is notified someone verified); never on wn_, wl_, cr_, kb_; self-views excludedbuilt
wall badgescounts of distinct signers only; never names or hashesbuilt
receipt-proof and tree-headspublic, cached, no PII; rate-limited against enumerationbuilt
anchor repositorynon-PII by construction; receipt rows stay deletable while anchors survivebuilt
document extractiona 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 ADRbuilt 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.

propertythe sentence it proveschapter
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 literals2.3 rules 1 to 302
the verifier, shared/merkleLog.js, and shared/courseReceipt.js canonicalize identically2.3 (the copies must not drift)02
assertJcsSafe accepts every grammar value and refuses every non-integer number and every undefined, wherever it sits2.3 rule 502
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 too2.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 copies2.502, 05
cr_ derivation agrees between the verifier and the server on random rows, or both refuse; "1" and "1.0" are different receipts2.402
base58btc round-trips, leading zeros included; did:key and the frozen kb_ payload agree between the verifier and the server4.1, 4.404
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 way4.104, 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 length5.405
a signed tree head verifies with exactly its seven fields, ignores a stray unsigned field, and refuses any changed field or signature character5.305
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 codes6.2 step 5, 6.406
a family key established after issuance still verifies, with a note and no failed step6.2 step 406
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 leaf6.2 step 5 check 006
the conformance suite matches every expected verdict, through the shell runner and through runVector in process10.110

Gaps the properties exposed

gapfound byconsequencestatus
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 runa receiver who trusted the verifier's last line rather than reading the leaf could be told the wrong receipt was in the logclosed 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 signaturedocumented: 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 testnone 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 affectedopen, 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 casesnone for the reference implementation; a cross-language risk for portsdocumented; 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 propertynone: the log is idempotent on (receipt_id, sha256) and both fields are inside every leaf, so two leaves can never be byte-identicaldocumented 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.