Foundation PoH — threat model
Scope: the proof-of-humanity and anonymous-voting subsystem.
Covers what the system defends against, what it doesn't, and where each mitigation lives in the code. Written at the close of PoH Phase 4. Refresh whenever a new enrollment method or vote path ships.
Threat taxonomy
T1 — Sybil: one human, many votes
Vector: an attacker creates many Foundation accounts and attempts to vote multiple times on the same proposal.
Defense in depth:
- Firebase Auth + invitation flow. Sign-up requires an invite (
invites/{email}) written by an admin, or a self-request that an admin approves fromaccess_requests/{email}. No one-click account creation. (functions/index.js—sendInviteEmail,checkInviteOnSignup,approveAccessRequest) - Phase 6 rate limit on
requestSelfAccess. 5 requests per email per hour and 20 per IP per hour throttle the scraping approach to the admin queue. - Nullifier at PoH enrollment.
verifyPassportProofwritesidentity_proofs/{nullifier}wherenullifieris derived from the passport. One passport → one identity_proofs doc; collision is detected and returnsNULLIFIER_ALREADY_USED. - One commitment per proof.
attachSemaphoreCommitmentrejects if the proof already has a different commitment bound. - One vote per proposal per nullifier.
verifyAnonymousVotestores a per-proposal nullifier inanonymous_votes/{proposalId}/nullifiers/{hash}; re-submission returns 409.
Residual risk: a determined attacker with multiple passports (e.g. dual citizenship) has multiple nullifiers and can vote multiple times. This is accepted — Foundation treats each passport as a distinct humanity credential. Mitigated by low expected population of dual-passport holders.
T2 — Impersonation: someone else's ID photo
Vector: attacker uploads a photograph of a target's driver's license / national ID and a deepfaked selfie to pass manual review as the target.
Defense:
- Self Protocol NFC path (HIGH trust). Requires physical possession of the chipped document and the device-side Groth16 proof. Out of reach for this vector.
- Manual review (LOW trust only). Manual review only grants
trustTier: 'low', which is insufficient to vote on MEDIUM-minimum proposals. A successful impersonation buys the attacker the ability to support a proposal but not cast a vote on most governance. - Liveness capture prompt. The selfie step asks for "hold up 3 fingers for liveness" — a one-bit liveness check that makes trivial photo reuse harder. Not bulletproof against a skilled deepfaker.
Residual risk: a motivated attacker with photoshop skill and a target's ID can reach LOW tier. Mitigated by trust-tier gating on critical proposals.
T3 — Coordinated manual-review forgery
Vector: a group manufactures multiple fake IDs and submits them as separate manual_review_requests.
Defense:
- Same-admin review queue. All manual-review submissions land in the admin UI, where patterns (same device fingerprint, same IP via Firebase Auth logs, near-identical reason text) are visible.
- PoH Phase 4 abuse registry. Admins can flag a voterId via
flagUserAbuse. Flagging:- Writes
abuse_registry/{voterId}: { active: true } - Deletes the user's
identity_proofs/*docs (therebuildSemaphoreGroupstrigger rebuilds all three tier caches, evicting the commitment) - Future enrollment attempts are blocked at
verifyPassportProofandapproveManualReview
- Writes
Residual risk: before the first flag, a burst of N fake IDs may get approved. Mitigated by LOW tier default + admin review time.
T4 — Vote-buying / coercion
Vector: a buyer pays voters to vote a particular way on a given proposal and demands proof of their vote.
Defense:
- Semaphore anonymity. Votes are submitted as ZK proofs; the
nullifierpublished on-chain is not linkable to the voter's identity_proof doc. A voter can't prove to a third party how they voted.
Residual risk: if the voter records their vote-casting UI (e.g. screen recording), the buyer gains off-chain evidence. No cryptographic defense against client-side recording. Foundation accepts this; it's a general limitation of privacy-preserving voting.
T5 — Group enumeration / de-anonymization
Vector: an observer tries to work backward from the public Semaphore group to identify which user cast a specific vote.
Defense:
- Tier-filtered groups (Phase 7A). The public
semaphore_groups/{tier}doc contains the commitment list and Merkle root. Commitments are cryptographic (Poseidon), not identifying. - Anonymity set = group size. Each vote is indistinguishable within the tier the proposal allows. Minimum group size target: 20 commitments per tier before a proposal is opened for voting.
Residual risk: in the early devnet period, group size may be <20 and timing analysis (one user enrolled right before the vote → vote arrived shortly after) could narrow the anonymity set. Mitigated as the population grows; documented as an early-stage acceptable risk.
T6 — Proof-root spoofing
Vector: a client submits a Semaphore proof whose merkleTreeRoot doesn't match the tier-cached root, hoping the server verifies the proof against an attacker-controlled group.
Defense:
verifyAnonymousVotecomparesproof.merkleTreeRootagainstsemaphore_groups/{tier}.rootbefore running the zk verifier. Mismatch → 403. (Added Phase 7A.)
Residual risk: none identified.
T7 — Custodial key compromise
Vector: the user_wallets/{uid} collection contains KMS-encrypted Solana keypairs. If the KMS key is compromised, all user wallets are exposed.
Defense:
- KMS envelope encryption with a Cloud KMS key the Foundation project manages.
firestore.rulesblocks all client reads ofuser_wallets/*.- Rotation plan: if KMS compromise is suspected, rotate every key in the collection and re-sign outstanding transactions. Documented explicitly in
firestore.rules.
Residual risk: a one-time compromise of the KMS + a Firestore read gives the attacker all wallets. Accepted for devnet; a production deploy would layer HSM-backed KMS.
T8 — Access-request queue DoS
Vector: an attacker scripts the access-request form to fill access_requests/{email} with garbage so admins can't find real requests.
Defense:
- Phase 6. Direct client writes to
access_requests/*are blocked byfirestore.rules. Writes only go throughrequestSelfAccessonCall, which applies the 5/email/hr + 20/IP/hr sliding-window rate limit. - TTL cleanup. Counter docs expire after 24h via the
access_request_rate(_ip)TTL policies.
Residual risk: distributed attacker with many IPs + many emails could still fill slowly. Flagging at the admin level + raising the per-IP cap downward is the escalation path.
T9 — Abuse registry abuse
Vector: an admin with Ring.TENANT_ADMIN flags a legitimate user to silence dissent.
Defense:
flagUserAbuseandunflagUserAbuserecordflaggedBy/unflaggedByfields. The audit trail is in Firestore.- Unflagging restores the user's ability to re-enroll (but does not restore deleted proofs — they must complete PoH again). This makes the action reversible.
- Only Ring 1 can flag. Ring 0 (platform owner) retains override.
Residual risk: no technical defense against a malicious single admin. Accepted; Foundation relies on the multi-admin social contract and the audit trail.
E2E coverage
evoting-frontend/e2e/register-flow.spec.ts covers:
- Landing renders the request-access form when unauthenticated
/registeris unreachable without auth (AccessGate redirects to Landing)- Direct write attempts to
access_requests/*fail (firestore.rules)
Full Self-based PoH enrollment can't be driven headlessly (requires the Self mobile app). That path is exercised manually during release validation.
Known unmitigated
- mDL (ISO 18013-5). Not implemented. See
docs/poh-mdl-status.md. - App Check enforcement. The
enforceAppCheck: trueflag is not yet set on any callable. Requires a reCAPTCHA Enterprise sitekey to be provisioned in the Firebase console first; tracked as a Phase 6 follow-up. - Reconciliation. On-chain / Firestore drift detection is deliberately skipped (Phase 10 — no drift source today). Revisit when any write path exists that doesn't go through a Cloud Function.
Revisit triggers
Refresh this document when any of the following ships:
- A new PoH enrollment method (mDL, eIDAS, WorldID, etc.)
- A new vote path (e.g. proxy / delegation)
- App Check enforcement is enabled
- The anonymity set per tier crosses below 20
- Any admin-level abuse incident requires a post-mortem