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. Refresh whenever a new enrollment method or vote path ships.
Revised 2026-09-03 for the Rarimo cutover. This document was written at the close of PoH Phase 4, when enrollment ran through Self Protocol and anonymity came from a Semaphore group-membership proof. Both were retired on 2026-08-31 (commit
7f6d77ba). The mitigations below have been re-derived against the current source — principallyfunctions/lib/proposal-nullifiers.js,functions/anonymous-vote.js, andfirestore.rules— and the anonymity analysis in T4/T5 has been rewritten, not find-and-replaced, because the anonymity set is a genuinely different quantity now. For the architecture itself seedocs/identity-architecture-current.md.Two things this revision deliberately does not do: it does not claim a security property it could not trace to source, and it does not delete the dated notes embedded below. Where an inline note concerns a retired path it is marked moot rather than removed.
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.
mintPohRoot(functions/lib/poh-root.js:30) writesidentity_proofs/{nullifier}, wherenullifierispub_signals[0]of the RariMe passport proof, derived from the passport chip under the deployment's base event ID. The write uses.doc(nullifier).create(), not.set()— a second enrollment with the same passport cannot overwrite the first and returns{ created: false }. One passport → one PoH root, enforced structurally by Firestore rather than by a read-then-check. - One vote per proposal per nullifier.
claimProposalNullifierTx(functions/lib/proposal-nullifiers.js:96) claimsproposal_nullifiers/{proposalId}/nullifiers/{nullifier}inside a Firestore transaction usingcreate(). A repeat claim raisesALREADY_EXISTS→ 409. The module's central rule is tombstone, never delete (proposal-nullifiers.js:11-15): "A claimed nullifier stays claimed forever. If a claim could be released, the attack is obvious — vote, release, vote again." - The vote-proof session is single-use. After a successful claim,
castRarimoVoteImplmarks the sessionstatus: "used"rather than deleting it (proposal-nullifiers.js:184-186), and refuses a used session up front (:155-158), so one proof request cannot be replayed against a second option.
The retired path's two Sybil bullets —
verifyPassportProof'sNULLIFIER_ALREADY_USEDcollision check andattachSemaphoreCommitment's one-commitment-per-proof rule — are gone with their callables (commit7f6d77ba). Neither is a lost mitigation: thecreate()-not-set()semantics above enforce the same uniqueness at the database layer, andidentity_proofs.commitmentis now vestigial and ignored by the access gate (functions/lib/poh-gate.js:25-28), so there is no longer a commitment binding to defend.
- ToS one-account-per-human clause (v3, 2026-05-15). Explicit prohibition on multi-account ownership, document recycling, multi-document enrolment, and proxying; spells out enforcement (suspension of all linked accounts + invalidation + abuse-registry + civil/law-enforcement referral). Surfaced on web and Foundation Mobile iOS via the shared
getTermsOfServicecallable. Seefunctions/legal.jsSINGLE_ACCOUNT_SECTION.
Moot since 2026-08-31. The note below concerns the Self Protocol enrollment path and its
SelfEnrollment.tsxclient, both retired in commit7f6d77ba. It is kept as a record of a real, empirically-confirmed Sybil hole in the retired path — and as a live warning that a stub/dev provider can silently neutralise the uniqueness guarantee, which is still true of the current stack: perfunctions/anonymous-vote.js:197-209, whenFOUNDERS_PASSPORT_PROVIDERisstub(the default)getPassportProviderdiscards the per-proposaleventIdand the stub nullifier is a hash of the random session id, so every proof request yields a fresh nullifier and the double-vote guard is inert end-to-end in dev and CI. Voting twice in a dev environment proves nothing. The guard is proven separately against real Firestore byfunctions/__emulator__/proposal-nullifier-idempotency.emulator.js.Resolved 2026-07-05 — PR #181.
devMode/endpointTypeare now derived fromprojectId === 'solanavote-devnet'(the same prod-detection sentinelcloudFunctionsBasealready used), matching the gating this note called for — but note it's gated on the app's own prod/staging split, notimport.meta.env.DEVas originally suggested here (DEV is false for both staging and prod builds; that sentinel would have wrongly forced staging into prod-mode against aSELF_DEV_MODE=truestaging server). Locked in by a regression test (SelfEnrollment.test.tsx). Caveat: not yet verified end-to-end against Self's real production infrastructure — that requires a physical device + a real passport scan, unavailable in the environment this fix was authored in. Do a controlled real-device enrollment test at/immediately after the next prod cutover, with rollback ready.Original gap description, kept for context: The nullifier collision check above describes the server's contract and is correctly implemented. It is bypassable in the currently deployed build because the client constructs the Self SDK with
devMode: true(evoting-frontend/src/components/identity/SelfEnrollment.tsx:76). In dev mode the SDK accepts mock-passport scans that do not derive a stable, chip-bound nullifier, so the same physical document yields different nullifiers on each enrolment and the server collision never matches. Empirically confirmed 2026-05-15 (one passport → two verified Firebase accounts).
Residual risk (still accepted by design): a determined attacker with multiple distinct passports (e.g. dual citizenship) has multiple legitimately-derived nullifiers and can vote multiple times. Foundation treats each chipped passport as a distinct humanity credential. Mitigations: low expected population of dual-passport holders; ToS v3 now explicitly forbids the conduct and threatens enforcement; planned cross-document face-geometry dedup would close it technically (not yet implemented).
See docs/security-hardening-2026-05-15-single-account.md for the full hardening writeup and follow-ups.
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:
- RariMe ePassport NFC path (HIGH trust). Requires physical possession of the chipped document and a device-side Groth16 proof over the ICAO 9303 passive-authentication chip data, generated in the RariMe app. A photograph of a document cannot produce it.
trustTierFor("rarimo-passport")→high(functions/lib/tier.js:42-43). - 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.
Caveat added 2026-09-03 — tier gating is not the only way to reach HIGH.
adminManualApproveHumanity(functions/user-management.js:433), a Ring-gated (Ring.TENANT_ADMIN) bypass for stuck verification, writes anidentity_proofsdoc withproofType: "admin-manual"andtrustTier: "high"as a hardcoded literal (:482,:487) — a tiertrustTierForwould never compute, since it has no"admin-manual"branch and would fall through tolow. An attacker who can persuade a tenant admin to unstick them therefore reaches HIGH without any document check at all. This is an audited, deliberate escape hatch, not a defect, but it means "HIGH tier implies a verified passport chip" is false as a blanket statement. Read the write site, nottrustTierFor. This also widens T9 below.
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 — eviction is now a check, not a deletion. Admins flag a voterId via
flagUserAbuse. Flagging writesabuse_registry/{voterId}: { active: true }and that is all it writes. The PoH root is deliberately left in place (functions/index.js:3297).This is the single largest structural change to the eviction mechanism, and it is worth being precise about because it inverts how eviction works. Under Semaphore, a flag deleted the user's
identity_proofs/*docs, which fired arebuildSemaphoreGroupstrigger that rebuilt the tier caches and evicted the commitment from the Merkle tree — eviction was a state change that removed the user's ability to produce a valid proof. Neither the trigger nor the group exists now. Under Rarimo a nullifier is derived from the passport and the event ID, so there is no membership list to remove anyone from, and deleting the PoH root would not stop a proof being produced anyway. Eviction is therefore a read-time predicate:startAnonymousVoteProofImplreadsabuse_registry/{uid}and refuses to issue a proof request whenactive === true(functions/anonymous-vote.js:144-155).flagUserAbuse's own comment says the deletion was "a far blunter instrument than the eviction it was standing in for."
Residual risk: before the first flag, a burst of N fake IDs may get approved. Mitigated by LOW tier default + admin review time.
Residual risk added 2026-09-03 — the abuse check is not re-run at claim time. Because eviction is now a predicate evaluated only when a proof request is issued, and the flow is deliberately split across two calls, there is a window the retired design did not have:
startAnonymousVoteProof(authenticated) checksabuse_registryand mints a session.castAnonymousVote(unauthenticated,functions/anonymous-vote.js:216) claims the nullifier.castRarimoVoteImplperforms no abuse check — it cannot, since it has no uid by design.- Session documents carry
createdAtbut no expiry field, and nothing reads one; there is no TTL policy onproposal_vote_sessionsand no staleness check incastRarimoVoteImpl.
So a session started before a flag remains castable indefinitely after it. Whether this matters depends on how promptly flags are applied relative to open voting windows; it is a real gap, not a theoretical one, and it is recorded as a Plan A follow-up (a createdAt staleness check in castRarimoVoteImpl would close it without re-introducing a uid). Note this is a narrow window in one respect: it admits only voters who were already eligible and had already requested a proof, not new Sybils.
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:
- Per-proposal nullifier unlinkability. The stored vote is
proposal_nullifiers/{proposalId}/nullifiers/{nullifier}holding only{ votedAt }(functions/lib/proposal-nullifiers.js:108). No uid, no wallet, no identity-proof reference, and nooptionId— andfunctions/__tests__/proposal-nullifiers.test.jsasserts the exact key set so a future edit cannot quietly add one. The nullifier is derived by Rarimo's query circuit fromskIdentity + eventID, and each proposal gets its own event ID (proposalEventIdFor,:47), so the same passport yields a different nullifier per proposal — no cross-proposal correlation and no link back to the registration nullifier. - The vote-casting call carries no identity.
castAnonymousVoteis an unauthenticatedonRequeston purpose (functions/anonymous-vote.js:19-22): "an auth token here would re-link the voter to the vote." The eligibility decision happens in the earlier authenticated call and is then thrown away — the session document holds exactly{ proposalId, eventId, status, createdAt }and deliberately no uid (:157-176). - Anonymous proposal votes are not mirrored on-chain.
castRarimoVoteImplincrements the Firestore counters onproposals/{id}and stops (:179-186); it enqueues no on-chain task. There is no per-vote on-chain record carrying a custodial wallet address for this path, so the wallet-to-vote correlation that would otherwise be available on Solana does not exist here.
Resolved 2026-09-04 — receipt-freeness gap. This section previously recorded a confirmed non-property:
proposal_nullifiers/{proposalId}/nullifiers/{nullifier}stored{ optionId, votedAt }and was world-readable (firestore.rules), and the nullifier is voter-reproducible (Rarimo's query circuit:skIdentity + eventID, recomputable by the same voter at will). Chained together, a voter could look up their own choice in a public document and hand it to a vote buyer as proof — the exact property receipt-freeness exists to prevent.Brought to the user as a decision (three options: accept as a documented trade-off, close the public per-vote read entirely and move to an aggregate-only tally, or strip only the receipt-generating field). The user chose the third: stop persisting
optionIdunder the voter-reproducible key, keep the document world-readable for existence only.claimProposalNullifierTx(functions/lib/proposal-nullifiers.js:96) now writes{ votedAt }only —optionIdstays a required argument (a claim must represent a vote for something, and the caller needs it separately) but is never persisted. A voter can still self-verify their nullifier landed (the reason this collection is public at all); the tombstone/no-double-vote property stays publicly auditable (a claimed nullifier exists, once, forever); nothing publicly readable states which option that vote was for.The public per-option tally was never gated on this document —
castRarimoVoteImplalready incrementsproposals/{id}.options.{optionId}.votesandtotal_votesserver-side (:179-186), andproposals/{proposalId}is separately world-readable (firestore.rules:34-35). Public auditability of results is therefore unchanged; only the per-voter receipt is gone.RariMe client-exposure question — checked 2026-09-04, against
rarimo/rarime-ios-app(public source) and this fork's owndagangilat/foundation-mobile-next, which embeds the same manager classes unmodified. Two separate questions, two different answers:Does the stock app UI display the nullifier value anywhere? No. Every occurrence of
nullifierin the iOS app was traced to its use site: the poll screen (PollView.swift:194) computes it only to silently callcheckUserVote(); the proof-consent screen a user actually sees (ProofRequestView.swift) lists disclosed-field categories as chips (e.g."Incognito ID",QueryProofSelector.swift:26) but never renders the computed value; every other reference is an internal API-call parameter. NoText(nullifier)or equivalent exists anywhere in the app.Can a voter obtain it anyway? Yes, trivially, without needing the app's cooperation.
UserManager.generateNullifierForEvent(eventId)(UserManager.swift:556) →profile.calculateEventNullifierHex(eventId)is an unauthenticated, ungated, on-device call needing only the voter's own already-unlocked profile and aneventIdstring — and theeventIdis not secret to the voter, since their own client necessarily receives it in plaintext as part of the ordinary proof-request flow (VerificatorApi'sproof-paramsresponse, read directly byProofRequestView) in order to scope the proof at all. The underlying logic also isn't sealed inside a compiled binary — it lives in a public, standalone SDK (rarimo/rarime-mobile-identity-sdk,profile.go) — so a moderately technical voter doesn't need to reverse-engineer or instrument anything; calling the SDK directly, or forking the (unencrypted, open-source) app to add one display line, both work.Net effect on this section's conclusion: unchanged, now evidenced rather than assumed. The prior text treated "RariMe surfaces the nullifier" as the conservative, unverified assumption behind calling this a non-property. That assumption was correct in substance (a determined voter can get the value) even though the specific mechanism guessed at — a one-tap in-app display — doesn't exist. The fix above was the right one regardless: it removes the receipt itself rather than trying to keep the nullifier from a voter who can always recompute it.
Not addressed by this fix, and not resolved by it: any
optionIdalready published before this fix landed is not retroactively unpublished — this closes the gap going forward, not historically, and (per the emulator idempotency test) the tombstone rule means those older documents will never be rewritten to drop it either.
Residual risk: in addition to the receipt above, a voter who screen-records the vote-casting UI gives the buyer off-chain evidence. No cryptographic defense exists against client-side recording; that part is a general limitation of privacy-preserving voting and remains accepted.
T5 — De-anonymization: which member cast this vote
Vector: an observer works backward from a recorded vote to the member who cast it.
What the anonymity set actually is now. Under Semaphore this had one answer — the size of the tier's Merkle group — because the group was the thing a proof proved membership in. There is no group any more, so there is no longer one number. The honest answer is that the anonymity set depends entirely on who is observing, and it is materially smaller for a privileged observer than for the public. Stated in three layers, each traced to source:
(a) Public observer — Firestore reads only.
proposal_nullifiers/{proposalId}/nullifiers/{nullifier} is world-readable (firestore.rules:357-363) and holds only { votedAt } (optionId is deliberately not persisted here — see the T4 receipt-freeness fix above). proposal_vote_sessions is closed in both directions (firestore.rules:369-372) — its own rule comment says a public read "would let anyone enumerate in-flight votes." A public observer therefore sees an opaque nullifier and a timestamp, with nothing to join against and no per-voter choice. The public per-option tally is available separately, in aggregate only, on the world-readable proposals/{id}.options.*.votes.
The anonymity set at this layer is every member eligible to vote on that proposal — i.e. holding a PoH root, in the proposal's tenant, at or above its minTier, and not abuse-flagged (functions/anonymous-vote.js:100-155) — not merely the members who actually voted, because the observer cannot tell eligible-and-abstained from eligible-and-voted-under-some-other-nullifier.
This rests on Rarimo's nullifier construction, and it is worth being exact about the epistemic status: Foundation's code trusts provider.getProofNullifier() (proposal-nullifiers.js:167) and does not itself verify that the nullifier is correctly derived from skIdentity + eventID, nor re-verify the Groth16 proof. The unlinkability property is inherited from Rarimo's circuits (the same Halborn-audited ZK-passport circuits used in Russia2024 and Iranians Vote), which Foundation self-hosts and audits but did not write. Say "relies on," not "guarantees."
(b) Platform operator — Cloud Logging + Firestore + the self-hosted verificator. This is much weaker, and the code says so: functions/anonymous-vote.js:15-17 explicitly points at "the residual log-correlation risk this does not eliminate" (recorded as an open decision at docs/superpowers/plans/2026-08-31-foundation-next-web-identity-consolidation.md:1224). Concretely, an operator has:
startAnonymousVoteProofis an authenticated callable, so Cloud Functions request logs carryuidand a timestamp within milliseconds of the session'screatedAt.- The session's
usedAtis written immediately after the nullifier document'svotedAt(proposal-nullifiers.js:170-186), in a deterministic order, so session and nullifier are joinable by timestamp. verificator-svcis self-hosted by Foundation (infra/verificator/README.md) and sees the mapping from session id to proof, and therefore to nullifier. It is inside the operator's trust boundary, not outside it.
Chaining these gives uid → sessionId → nullifier → optionId. The anonymity set against the operator is therefore not the eligible population but the number of proof requests concurrent within the timing-resolution window — which in a low-traffic deployment, or for a proposal with few voters, is frequently 1. Anonymity from Foundation itself is not a property this design provides; it defends the voter against other voters and the public, and against an operator only to the extent that logs are not retained or correlated. Log retention and access control are the real control here, and they are outside this document's scope.
(c) Coercer / vote buyer. See the receipt-freeness warning under T4: the voter can voluntarily collapse their own anonymity set to 1.
Residual risk — the 20-member floor has no replacement. The retired model set a minimum group size of 20 commitments per tier before a proposal opened for voting. No equivalent gate exists in the current code: a grep across functions/ and evoting-frontend/src/ for a minimum-anonymity-set or minimum-turnout check returns nothing, and neither startAnonymousVoteProofImpl nor castRarimoVoteImpl consults a turnout floor. A proposal whose eligible population is 3 will happily accept votes. Nothing warns an operator, and nothing warns the voter. Recorded as a Plan A item; until it exists, small-population proposals should be treated as non-anonymous in practice regardless of the cryptography.
T6 — Event-ID scope confusion (replaces proof-root spoofing)
Vector: the current analogue of root spoofing. A client tries to get a nullifier claimed under the wrong scope — either reusing a session against a different proposal, or inducing the server to fall back to the registration event ID, which would produce a nullifier directly linkable to the member's enrollment.
Defense:
- Sessions are bound to one proposal.
castRarimoVoteImplcompares the request'sproposalIdagainst the session's, and 403s on a mismatch (proposal-nullifiers.js:143-148): "A session is bound to one proposal because its event ID is. Reusing it elsewhere would claim a nullifier from the wrong namespace." - The derivation cannot silently fall back.
getPassportProviderresolves the event ID asopts.eventId || FOUNDERS_PASSPORT_EVENT_ID.value(), so an empty string would silently scope the proof to the registration event and return the member's registration nullifier — a directly linkable, non-anonymous vote, with no error raised anywhere.proposalEventIdFortherefore validates its own output and throws rather than ever returning a falsy value (proposal-nullifiers.js:35-45, 62-69). The check is unreachable by construction today; it exists so a future defect in the derivation fails loudly instead of collapsing into a silent anonymity break. - Event IDs are namespaced to the deployment. Each proposal's event ID is derived by hashing the deployment's base event ID with the proposal id (
:54-60), which is what keeps this fork's nullifiers disjoint from live Foundation's.
Residual risk: the stub provider discards eventId entirely (see the stub caveat under T1), so none of the above is exercised in dev or CI. The scope binding is only real when FOUNDERS_PASSPORT_PROVIDER is the real Rarimo provider. No production risk identified.
T6b — Unauthenticated vote endpoint: no rate limit
Vector: castAnonymousVote is an unauthenticated onRequest with cors: true and, being onRequest rather than a callable, no App Check (functions/anonymous-vote.js:216-232). An attacker POSTs repeatedly.
Defense: the method check (405 on non-POST); the status === "used" short-circuit, which turns a replay into a cheap local 409 "instead of a verificator-svc call" (proposal-nullifiers.js:149-158); and session ids being v4 UUIDs (anonymous-vote.js:158), which are not guessable, so an attacker cannot enumerate other members' in-flight sessions.
Residual risk — a comment/code mismatch worth fixing. proposal-nullifiers.js:124-125 states that "Method-check and rate limiting stay in the wrapper," but the wrapper implements only the method check; there is no rate limiting anywhere in this path. Each POST against a still-pending session costs one verificator-svc round-trip, so an attacker holding one valid session id can drive unbounded load onto the verificator. The nullifier claim itself is unaffected — correctness does not depend on the missing limiter — so this is availability and cost, not vote integrity. Recorded as a Plan A item; documented here rather than fixed because this pass changes no application code.
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 is now fully reversible — corrected 2026-09-03. The retired text said unflagging "does not restore deleted proofs — they must complete PoH again." That is no longer true, and in the user's favour:
flagUserAbuseno longer deletes the PoH root (functions/index.js:3297), so unflagging only clearsactiveon theabuse_registrydoc and there is nothing to restore. The user's existingidentity_proofsdoc immediately admits them again throughpohRootAdmitsandstartAnonymousVoteProof(functions/index.js:3332-3338). A wrongly-flagged user no longer has to re-scan their passport to recover — which also removes the retired design's perverse incentive, where a reversible administrative action had an irreversible consequence for the victim. - 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.
Residual risk widened 2026-09-03 — admins can mint HIGH trust directly. A Ring 1 admin's power is not limited to denying access. adminManualApproveHumanity (functions/user-management.js:433) writes an identity_proofs doc with trustTier: "high" and no document check (see the caveat under T2). A malicious admin can therefore manufacture verified-human voters on the highest tier, not merely silence real ones — a Sybil vector that bypasses all of T1's defenses, since it never touches the passport path at all. The audit trail records it, and this is a deliberate escape hatch for stuck verification, but any future review of admin-abuse containment should treat this as the more serious of the two directions.
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 PoH enrollment can't be driven headlessly (it requires the RariMe mobile app and a physical chipped passport). That path is exercised manually during release validation.
The double-vote guard is covered, but not by the E2E suite. Because the default stub provider makes the guard inert end-to-end (see the stub caveat under T1), an HTTP-level E2E test of "vote twice" would prove nothing. The guard is instead proven against real Firestore semantics by functions/__emulator__/proposal-nullifier-idempotency.emulator.js, with the key-set invariant (no uid ever added to a nullifier document) asserted in functions/__tests__/proposal-nullifiers.test.js and the eligibility rules in functions/__tests__/anonymous-vote-eligibility.test.js. Anyone extending this subsystem should add to those, not to the Playwright suite.
Known unmitigated
- mDL (ISO 18013-5). Not implemented. See
docs/poh-mdl-status.md. - 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.
Resolved 2026-04-20 — App Check is now enforced on all callables via the
functions/lib/app-check.js::callable()helper (commitb997a66c). The reCAPTCHA Enterprise sitekey is provisioned in both staging and prod.
Provider & architecture decisions (deliberate "no"s)
Recorded 2026-07-05 following an outside advisory review of the wider Proof-of-Personhood landscape (World ID, Human Passport, Humanity Protocol, BrightID, Proof of Humanity, Idena), so these don't get relitigated from scratch each time a new PoP vendor makes headlines.
Dated 2026-07-05 — the reasoning stands, one premise has changed. This section was written while the hard credential was Self Protocol and the privacy layer was Semaphore; both were replaced by Rarimo on 2026-08-31. The conclusions are unaffected — the architecture is still hard-credential + privacy-layer + graded trust tiers, and every rejection below turns on that shape rather than on which vendor supplies it. Two specifics are now stale and are left in place as a record of what was decided when: the "Self passport-NFC + mDL" phrasing below (read: RariMe ePassport NFC), and the World ID note's remark that "Self's own roadmap (Aadhaar/national-ID support) may close that gap with the incumbent vendor first" — Self is no longer the incumbent, so that particular closing path no longer applies and the document-less-user gap is correspondingly more open than this text assumes.
No passive ML/behavioral scoring layer (e.g. Human Passport's Models API), on top of the existing hard-credential tiers.
Foundation's low/medium/high trust tiers already are a multi-signal architecture — the industry's converging pattern — but expressed as discrete, auditable facts ("this voter's tier is high because their passport chip verified") rather than a continuous, vendor-scored probability. That's the right shape for a platform selling verified-human governance to municipalities: a probabilistic score is harder to defend in a governance dispute or to a city clerk than a stated credential. Structurally, a scoring layer like Human Passport's Models API scores organic on-chain wallet history — Foundation's wallets are custodial and platform-minted, so every user's on-chain history is generated by Foundation's own Cloud Functions, not an organic signal a scorer could read anything from. Revisit only if a genuinely different, non-wallet-based passive signal is needed for a specific measured problem (e.g. bot traffic at the request-access queue) — that's a rate-limiting/App-Check problem, not a PoP-scoring one, and is already partially covered (see T8).
No new PoP vendor (Humanity Protocol or otherwise) added without a measured funnel gap.
Foundation already runs the architecture the PoP field is converging on: hard credential (passport-NFC + mDL — Self at the time of writing, RariMe/Rarimo since 2026-08-31) + privacy layer (ZK nullifiers — Semaphore at the time of writing, per-proposal Rarimo nullifiers since) + graded trust tiers. Every vendor surveyed would be a step sideways or backwards from it for this platform's actual constraints (invite-only onboarding, municipal/coop customers, custodial wallets) — see the full comparison in import/proof_of_personhood_landscape.md if evaluating this again.
- Humanity Protocol revisit trigger (the vendor with the most superficial appeal, given Foundation's EVM adapter already exists): revisit only if all of (a) Humanity Protocol ships public independent audits, (b) full public enrollment opens (not open as of the landscape review), and (c) there's a documented, measured churn of pilot users who lack both an ePassport and an mDL and refuse the document-photo/manual-review fallback. Even then, the integration is scoped as small: one new
proofTypeinfunctions/lib/tier.jsmapping tomedium— a day of schema work, not a platform redesign. Zero cost to waiting for the trigger. - World ID is the only other vendor worth a future look — it's the one that offers hard uniqueness for people without any document at all (Orb enrollment). Not now: Orb-visit friction is prohibitive for municipal pilot demographics, and Worldcoin's regulatory/brand profile is a live liability with exactly this customer segment. Same revisit condition as Humanity Protocol (a measured document-less-user funnel gap) — note Self's own roadmap (Aadhaar/national-ID support) may close that gap with the incumbent vendor first.
- BrightID, Proof of Humanity, Idena: no revisit trigger — structural mismatches with Foundation's userbase (BrightID needs an organic social graph civic users don't have; Proof of Humanity requires a public face video, antithetical to the ZK-anonymous-voting privacy property; Idena requires synchronous ceremonies at fixed times).
Revisit triggers
Refresh this document when any of the following ships:
- A new PoH enrollment method (mDL, eIDAS, WorldID, etc.) — see the specific Humanity Protocol / World ID revisit conditions above
- A new vote path (e.g. proxy / delegation)
- App Check enforcement is enabled
The anonymity set per tier crosses below 20— not currently checkable. There are no tiered groups any more and no minimum-anonymity-set gate exists in code (see T5's residual). Restore this trigger if and when such a gate is implemented; until then it cannot fire, and its presence in this list should not be read as evidence that anything is watching.- Any admin-level abuse incident requires a post-mortem
Open questions carried out of the 2026-09-03 revision
Recorded rather than answered, per this repository's rule against asserting an unverified security property. Each needs a real answer before any external security review.
Receipt-freeness (T4) — RariMe client-exposure question.Answered 2026-09-04, againstrarimo/rarime-ios-app's public source (and this fork's owndagangilat/foundation-mobile-next, which embeds the same manager classes unmodified). The stock app UI never displays the nullifier value — butUserManager.generateNullifierForEventis an unauthenticated, ungated on-device call, and the underlying logic lives in a public SDK (rarimo/rarime-mobile-identity-sdk), so a moderately technical voter can obtain it without the app's cooperation regardless. See T4's "RariMe client-exposure question" note above for the full trace. The conservative assumption this revision made was correct in substance; the specific fix already applied (T4,functions/lib/proposal-nullifiers.js) does not depend on this answer either way. This no longer needs a Rarimo/RariMe expert to confirm — the code trace above is direct evidence, not inference.- Operator-side anonymity (T5b). No log-retention or log-access policy is documented anywhere in this repository, yet it is the actual control bounding the operator's ability to correlate
uid → nullifier. Until one exists and is enforced, "anonymous voting" should not be described to municipal or co-op customers as anonymous from Foundation. - Minimum anonymity set (T5). No replacement for the retired 20-commitment floor. Someone must decide whether a floor is required and where it is enforced.
- Session staleness (T3). Whether the post-flag casting window is acceptable, or whether
castRarimoVoteImplshould reject sessions older than N minutes. - Independent verification of the nullifier derivation. Foundation trusts the value
verificator-svcreturns and does not re-verify the proof server-side. Self-hosting the verificator makes this a defensible trust boundary rather than a vendor dependency, but it is a trust boundary and should be named as one in any audit.
Items 3, 4 and the missing rate limit under T6b are code changes, recorded as Plan A follow-ups — this documentation pass deliberately changed no application code.