This document specifies how device seeds (transaction signing) and voting secrets (anonymous voting via ZK commitment scheme) are managed across multiple devices belonging to a single home.
- One anonymous vote per home: a ZK proof proves "I'm an eligible member" without revealing which one. A deterministic nullifier per (identity_key, proposal) prevents double-voting: even across secret rotations.
- Per-device transaction signing: each device has its own sr25519 keypair. Compromising one device does not compromise the others.
- Isolated identity key: the
identity_key(nullifier derivation + proof generation) never leaves the origin device. Paired devices share only thehome_secret(for membership). Compromising a paired device cannot deanonymize votes. - Revocable access: when a device is compromised, the home owner revokes it and rotates the
home_secret. Theidentity_keyis unaffected since it was never on the compromised device. - Recovery from full takeover: community governance can reset a home's devices. The owner restores voting identity from a mnemonic backup.
- Late registration: homes that register after a proposal is created can still vote on it (live Merkle tree, no snapshots).
| Key | Scope | Purpose | Crypto | Storage |
|---|---|---|---|---|
| Device seed | Per device | Sign transactions (extrinsics) | sr25519 | localStorage (encrypted via WebAuthn PRF or fallback) |
| Home secret | Per home | Membership proof (commitment in Merkle tree) | ZK commitment (Poseidon hash + Merkle proof) | localStorage (transferred via pairing QR) |
| Identity key | Per home | Nullifier derivation + ZK proof generation | Poseidon hash | Origin device only (WebAuthn PRF or fallback). Backed up via mnemonic (paper). Never transferred digitally. |
HomeId invariant: HomeId is a strictly monotonically increasing integer, never reused. When a home is removed, its ID is retired. The next applicant receives a new ID. This guarantees stable references across the chain (committee membership lookups, Merkle leaf indices in CommitmentLeafIndex, per-home storage keys, voting-history records) — a recycled HomeId would cause stale references to silently bind to a new occupant. Note that PII encryption deliberately does not include the HomeId in its HKDF info; see PII encryption below for why.
Each home has a home_secret (32 random bytes) and an identity_key (32 random bytes, derived from a mnemonic). At registration, the home publishes two hashes:
secret_hash = poseidon(1, home_secret)
identity_key_hash = poseidon(2, identity_key)
The pallet computes the commitment from these two hashes:
commitment = poseidon(3, secret_hash, identity_key_hash)
All Poseidon hashes use domain separation tags (the first argument) to prevent cross-protocol collisions and structural ambiguity in circuits. Tags are small integer constants encoded as BN254 scalars: 1 = secret, 2 = identity, 3 = commitment, 4 = nullifier, 5 = keygen. Integer tags are cheaper to represent in circuits and eliminate endianness/padding edge cases between the WASM frontend and Rust pallet.
The pallet stores identity_key_hash per home and maintains an indexed Merkle tree of all commitments. The tree root is the single public value that represents the set of all registered voters.
Because the pallet computes commitments from their component hashes, it can recompute a new commitment on secret rotation using the stored identity_key_hash: no ZK rotation proof is needed (see flow 3).
To vote, the origin device proves in zero knowledge:
- "I know a
home_secretandidentity_keywhose combined commitment is a leaf in the Merkle tree" - "The nullifier I'm submitting equals
poseidon(4, identity_key, proposal_id, genesis_hash)" - "This proof is bound to this specific chain" (via
genesis_hashpublic input) - "The vote direction I'm submitting is the one I committed to in this proof"
The pallet verifies the ZK proof against the Merkle root and checks the nullifier hasn't been used. It never learns which commitment (which home) voted.
Note: secret_hash and identity_key_hash are public per home, but commitments are already publicly mapped to home IDs (Commitments: HomeId -> commitment), so this leaks nothing new. Vote anonymity comes from the Merkle proof being private (in the ZK witness), not from commitments being secret. Crucially, identity_key_hash does not reveal identity_key (Poseidon is one-way), so no one can compute nullifiers to correlate votes.
Anonymity depends on homeId privacy. The ZK proof hides which commitment voted, but identity_key_hash is permanently mapped to homeId on-chain. If homeId ever becomes linkable to a real-world identity (e.g. through public records, social context, or metadata leakage from other on-chain activity), then identity_key_hash becomes a stable pseudonym: and all votes are retroactively attributable if identity_key is also compromised. The system provides unlinkability between votes and homes, not anonymity from the community itself.
The home_secret is what gets rotated on device compromise: it controls membership in the Merkle tree. The identity_key is permanent: it controls nullifier derivation. Because the identity_key never changes, nullifiers are stable across rotations. This eliminates nullifier migration entirely, removing the need for a MAX_OPEN_PROPOSALS constant and the complex rotation circuit that migrates nullifiers for every open proposal.
An attacker who steals the identity_key can fully deanonymize the home's votes: both participation and direction. The attack: pre-compute poseidon(4, identity_key, proposal_id, genesis_hash) for every active proposal, then match against nullifiers on-chain (stored in Nullifiers: (proposal_id, nullifier) -> VoteDirection). This can be done retroactively against all past proposals and proactively by monitoring the transaction pool for future cast_vote extrinsics. A single identity_key leak turns anonymous voting into public voting for that household, permanently.
The key mitigation is operational isolation: unlike the previous design where the nullifier-deriving secret was shared across all devices via QR, the identity_key never leaves the origin device. It is:
- Generated once during registration and stored via WebAuthn PRF (or fallback encryption)
- Backed up via a BIP39 mnemonic written on paper (physical security, not digital)
- Never transferred digitally: not via QR, not via any network protocol
- Only present in memory on the origin device during proof generation
This means compromising a paired device (which only holds home_secret) cannot deanonymize votes. The attack surface for identity_key theft is limited to the origin device itself or the physical mnemonic backup.
Because nullifier = hash(identity_key || proposal_id), an identity_key leak at any point in the future enables retroactive deanonymization of all past votes. There is no forward secrecy: the nullifier-deriving secret is static. The current design accepts this as a tradeoff for simplicity, relying on the operational isolation of identity_key (origin-device-only, mnemonic backup) to limit the probability of a leak in the first place. See Future work for potential mitigations (epoch ratchet, identity key rotation).
Ring VRF ties the nullifier to the signing key. Rotating the key produces a new nullifier, enabling a second vote on the same proposal. The ZK commitment scheme ties the nullifier to the identity_key, which never rotates, so key rotation cannot produce new nullifiers.
| Property | Ring VRF | ZK commitment |
|---|---|---|
| Nullifier bound to | Signing key | Identity key (permanent) |
| Key rotation enables revote | Yes | No |
| Late registration can vote | Only with live ring, but live ring also enables key-rotation revote attack | Yes (live Merkle tree) |
| Proof size | 784 bytes | 128 bytes (Groth16: 2 G1 + 1 G2, BN254) |
| Verifier cost | Scales with ring size | Constant (pairing check) |
When the home_secret is rotated (compromise response), other devices in the home hold a stale secret. Rather than syncing secrets on-chain, the home owner re-pairs each remaining device by generating a new pairing QR (see flow 2). This is acceptable because rotation is a rare emergency event, and the re-pairing cost (scanning one QR per device) is modest compared to the complexity of on-chain secret chaining. The identity_key is unaffected: it lives only on the origin device and is not part of pairing.
Membership applications include two pieces of personal information stored on-chain: the resident's name and phone number. These require different access levels:
| Field | Who can read | Sensitivity |
|---|---|---|
| Name | All community members (any home) | Low: needed for community directory, displayed in UI headers and member lists |
| Phone number | Committee members only | High: used for identity verification during approval, not needed by regular members |
Both are encrypted before submission and stored as ciphertext on-chain using X25519 hybrid encryption (ECDH + AES-256-GCM). Public keys are stored on-chain; private keys are held only by those authorised to decrypt.
Two X25519 keypairs, each generated once at community setup:
| Keypair | Encrypts | Public key | Private key |
|---|---|---|---|
| Community | Name | On-chain (CommunityPublicKey) |
All home devices (distributed during pairing) |
| Committee | Phone | On-chain (CommitteePublicKey) |
Committee devices only (distributed during election/setup) |
Anyone can encrypt (using the public key from chain storage). Only holders of the private key can decrypt. No secrets in the frontend bundle.
X25519 ECDH hybrid encryption (equivalent to libsodium's crypto_box_seal):
Encrypt (applicant or any client):
- Read recipient public key from chain storage (
CommunityPublicKeyorCommitteePublicKey) - Generate ephemeral X25519 keypair
shared_secret = X25519(ephemeral_private, recipient_public)aes_key = HKDF-SHA256(shared_secret, "pii-{purpose}")where purpose isnameorphone(domain separation by purpose prevents using a name ciphertext as a phone ciphertext or vice versa;homeIdis not included because PII is encrypted at application time before a homeId is assigned, and the pallet copies the ciphertext verbatim from the application to the home on approval — cross-home ciphertext transplant is not a concern since each encryption uses a unique ephemeral key)ciphertext = AES-256-GCM-encrypt(aes_key, plaintext)with random 12-byte IV- Store on-chain:
ephemeral_public (32 bytes) || IV (12 bytes) || ciphertext || tag (16 bytes)
Decrypt (member or committee):
- Parse stored data: extract
ephemeral_public,IV,ciphertext shared_secret = X25519(recipient_private, ephemeral_public)aes_key = HKDF-SHA256(shared_secret, "pii-{purpose}")plaintext = AES-256-GCM-decrypt(aes_key, ciphertext)
Each encryption uses a unique ephemeral keypair, so identical plaintexts produce different ciphertexts. The ephemeral private key is discarded immediately after encryption.
Size limits: X25519 hybrid encryption adds 60 bytes of overhead per ciphertext (32 ephemeral public key + 12 IV + 16 auth tag). Both MaxNameLen and MaxPhoneLen are 128 bytes, supporting plaintext up to 68 characters each. The frontend enforces maxLength on input fields (68 for names, 30 for phone numbers) to prevent oversized BoundedVec decoding failures on-chain.
Community private key: distributed to new members during device pairing. The pairing QR payload becomes: [version=0x01, type=0x01, home_secret (32 bytes), community_private_key (32 bytes)]: 66 bytes total.
Committee private key: distributed to committee members during election or initial setup. Two mechanisms:
-
Committee pairing QR (preferred): an existing committee member displays a QR containing the private key (same binary QR format as device pairing, with
type=0x03for committee key). New committee members scan it during their first committee session. This is a one-time physical-proximity transfer. -
Out-of-band (fallback): the private key is shared via a secure channel (encrypted message, in-person handoff). Acceptable for small committees where members know each other.
The pallet stores the public keys on-chain:
#[pallet::storage]
pub type CommunityPublicKey<T: Config> = StorageValue<_, [u8; 32], OptionQuery>;
#[pallet::storage]
pub type CommitteePublicKey<T: Config> = StorageValue<_, [u8; 32], OptionQuery>;Set at genesis or via a committee extrinsic (set_community_public_key, set_committee_public_key).
Committee keypair: rotated when a committee member is removed for cause:
- Committee generates a new X25519 keypair
- Publishes the new public key on-chain via
set_committee_public_key(new_public_key): callable only by committee origin - Distributes the new private key to remaining committee members (via committee pairing QR)
- A committee member's frontend re-encrypts all existing phone numbers:
a. Decrypt each with old private key
b. Re-encrypt with new public key (from chain)
c. Submit
rotate_encrypted_phones(Vec<(HomeId, new_ciphertext)>)in batches: callable only by committee origin (bounded toMaxPhoneRotationBatchentries per call, e.g. 50, to stay within block weight limits; the frontend loops through all homes and sends multiple transactions until complete) - The removed member never receives the new private key and cannot decrypt new or re-encrypted phones
Community keypair: rotated infrequently (scheduled, e.g. annually) or on security breach, not on every member removal:
- Committee generates a new X25519 keypair
- Publishes the new public key on-chain via
set_community_public_key(new_public_key) - Re-pairs all remaining members with the new private key (each scans a new pairing QR)
- New names (residents who join after rotation) are encrypted with the new public key
- Old names remain encrypted with the old public key: still readable by anyone who held the old private key
Names are "socially public" within the community: a removed member already knew everyone's name from the directory. Re-pairing every home on each removal is high friction for negligible security gain. Rotating the community key is only worthwhile when the private key itself may have been exfiltrated (e.g. device theft, suspected breach). Upon removal, the member loses access to the app (the primary way to fetch and decrypt ciphertexts); decrypting raw chain state requires technical effort they are unlikely to exert for names they already knew.
| Keypair | Rotation trigger | Method | Revocation |
|---|---|---|---|
| Committee | Committee member removed for cause | New keypair + re-encrypt all phones (batch extrinsic) | Full: old private key holder loses access after re-encryption |
| Community | Scheduled (e.g. annually) or security breach | New keypair + re-pair remaining members | Forward-only: old names readable, new names protected |
New applicants read the public keys from chain storage (CommunityPublicKey, CommitteePublicKey) and encrypt name and phone locally before submitting. The encrypted ciphertexts are included directly in the apply extrinsic: no commit-reveal needed.
No secrets are needed for encryption: public keys are meant to be public. Even an attacker who fully inspects the frontend source and chain state cannot decrypt existing data without the private key.
Recovery handles two independent problems: restoring device access (transaction signing) and restoring voting identity (identity_key).
If all devices are lost or compromised, community governance calls reset_home_devices(home_id, new_device_account) to revoke all current devices and register a new one. This uses the existing governance origin (e.g. board multisig or referendum): no per-home recovery account needed.
An attacker who has taken over a home's devices cannot prevent this: governance operates independently of home-level device authorisation. The owner contacts the community board, proves their identity out-of-band, and governance restores access.
After regaining device access, the owner restores identity_key from the mnemonic backup:
- On the new device, enter the mnemonic to re-derive
identity_key - The new device becomes the origin device (
gov-is-origin = true) - Verify: compute
poseidon(2, identity_key)and check it matches the on-chainIdentityKeyHashes[homeId] - Rotate
home_secret(flow 3) to invalidate the attacker's old secret - Re-pair additional devices (flow 2)
If both the origin device and the mnemonic are lost, the home must re-register with a new identity_key (remove_commitment + register_commitment). A new identity_key means new nullifiers for all proposals, so without safeguards the home could double-vote on any open proposal.
Why the pallet can't check at vote time: votes are anonymous: the pallet doesn't know which home is voting, so it can't check whether the voter re-registered during the proposal window. Any circuit change that exposes the leaf index or registration time would break vote anonymity.
Mitigation: register_commitment blocks re-registration while any proposal that was created before the remove_commitment call is still open. The pallet stores the block number of removal in CommitmentRemovedAt[home_id]. On re-registration, it checks that no proposal created before that block is still open. This allows the home to vote on proposals created after their re-registration, while blocking re-votes on proposals they could have already voted on.
This is narrower than a global freeze: the home only waits for proposals that existed at the time of removal to close, not all proposals. New proposals created after removal are unaffected. In a rolling governance system, this means the home is blocked for at most the duration of the longest-running pre-existing proposal, not indefinitely.
Governance parameter dependency: if the community allows long-running proposals (e.g. a 6-month constitutional amendment), any user who loses their mnemonic during that period is locked out of voting on all concurrent proposals until the long proposal closes. This is acceptable if proposal durations are bounded (e.g. max 30 days). If long-running proposals are needed, consider adding a MaxProposalDuration runtime constant and splitting long initiatives into time-bounded phases.
The check is O(1) using a single storage value EarliestOpenProposalBlock that tracks the minimum created_at block among all open proposals. When a proposal closes, the value is recomputed (or set to None if no proposals remain open). When None, re-registration is always allowed:
if let Some(removed_at) = CommitmentRemovedAt::<T>::get(home_id) {
if let Some(earliest_open) = EarliestOpenProposalBlock::<T>::get() {
ensure!(
earliest_open >= removed_at,
Error::ReregistrationBlockedWhilePreRemovalProposalsOpen,
);
}
// None => no open proposals => re-registration allowed
}User clicks "Register" on SignInPage
1. Generate deviceSeed (32 random bytes)
2. Generate homeSecret (32 random bytes)
3. Generate identityKey: derive from a BIP39 mnemonic (12 or 24 words)
entropy = bip39_to_entropy(mnemonic) // 128 or 256 bits
identityKey = poseidon(5, entropy[0..31]) // truncate to 248 bits (31 bytes)
A 24-word mnemonic produces 256 bits of entropy, but the BN254 scalar field
modulus is ~254 bits. Feeding a 256-bit value directly into Poseidon would
cause silent modular reduction or a panic depending on the library. Truncating
to 31 bytes (248 bits) guarantees the input is a valid field element while
preserving 248 bits of entropy: far more than needed for 128-bit security.
Poseidon (not a standard KDF) is used so the output is a BN254 scalar directly
usable in circuits without field reduction.
4. Display mnemonic for user to write down on paper
"This is your voting recovery phrase. Write it down and store it securely.
It is the only way to recover your anonymous voting identity if this device is lost."
5. Require user to confirm mnemonic (re-enter selected words)
6. Store deviceSeed encrypted via WebAuthn PRF (or fallback)
7. Store homeSecret in localStorage
8. Store identityKey encrypted via WebAuthn PRF (origin device only, never in plain localStorage)
9. Derive sr25519 keypair from deviceSeed -> account address
10. Set account as current, mark this device as origin device
Voting key registration (MyHomePage, after home membership is approved):
User clicks "Register Voting Key" (origin device only)
1. Recover homeSecret from localStorage, decrypt identityKey via WebAuthn PRF
2. Compute secretHash = poseidon(1, homeSecret)
3. Compute identityKeyHash = poseidon(2, identityKey)
4. Submit register_commitment(secretHash, identityKeyHash)
5. Pallet computes commitment = poseidon(3, secretHash, identityKeyHash)
6. Pallet inserts commitment as a leaf in the Merkle tree
7. Pallet stores identityKeyHash in IdentityKeyHashes[homeId]
8. Pallet stores leaf index in CommitmentLeafIndex[homeId]
New device (PairDevicePage, step 1: generate identity):
1. Generate deviceSeed (32 random bytes) locally
2. Derive sr25519 keypair -> account address
3. Store deviceSeed encrypted via WebAuthn PRF (or fallback)
4. Display account address for the original device (copyable text or QR)
Original device (MyHomePage "Link a new device", step 2: authorise & share secrets):
1. Enter or scan the new device's account address
2. Enter a label for the new device
3. Submit add_device(new_account, label) on-chain
4. Recover homeSecret from localStorage
5. Encode as binary QR payload: [version=0x01, type=0x01 (home_secret), homeSecret (32 bytes)]
(34 bytes total; version byte allows future protocol upgrades, type byte distinguishes
home_secret QRs from identity_key QRs to prevent cross-scanning errors)
6. Display as QR code with auto-dismiss after 30 seconds
(never as a URL: URLs leak into browser history, autocomplete, and logs)
The QR contains only homeSecret: a captured QR allows membership proofs
but NOT nullifier computation or vote deanonymization (identity_key is not included).
New device (PairDevicePage, step 2: receive secrets):
1. Scan QR from original device's camera view
2. Parse 34-byte payload: verify version=0x01, type=0x01 -> homeSecret
3. Store homeSecret in localStorage
4. Ready: can sign transactions. Voting requires the origin device (see flow 7).
The device seed never leaves the device that generated it: the original device never learns other devices' private keys. This ensures that compromising one device cannot compromise another's signing key.
If the secret was rotated after the pairing QR was generated, the new device will hold a stale homeSecret. On first load it detects the mismatch (local commitment ≠ on-chain commitment) and prompts the user to re-pair.
QR security considerations: treat any QR containing secrets like a private key. Risks beyond shoulder-surfing include: camera apps that auto-upload to cloud storage (iCloud, Google Photos), screen recording / accessibility overlays, and OS-level clipboard logging. Mitigations: disable screenshots while QR is displayed (platform API), auto-dismiss after 30 seconds, require explicit tap to show. For higher security environments, consider Noise protocol pairing over local network (Bluetooth/WiFi Direct) instead of QR: use QR only to bootstrap the encrypted channel, not to carry the secret directly.
Triggered when a device is compromised.
Rotation replaces the commitment in the Merkle tree. The pallet recomputes the new commitment using the caller's new secret_hash and the stored identity_key_hash: no ZK proof needed. Because nullifiers are derived from identity_key (which never rotates and is not on the compromised device), no nullifier migration is needed either.
Trusted device (MyHomePage "Rotate Voting Key"):
1. Generate new homeSecret (32 random bytes)
2. Compute new_secret_hash = poseidon(1, new_homeSecret)
3. Submit rotate_commitment(new_secret_hash)
4. Pallet retrieves stored identityKeyHash for this home
5. Pallet computes new commitment = poseidon(3, new_secret_hash, identityKeyHash)
6. Pallet replaces commitment in Merkle tree (indexed update)
7. Update local homeSecret in localStorage
8. Re-pair other devices with new pairing QR (see flow 2)
After rotation:
- Existing votes are preserved: nullifiers are unchanged (derived from stable
identity_key) - Old secret can no longer produce valid proofs (commitment removed from tree)
- New secret produces the same nullifiers as before: attempting to vote again on a previously voted proposal is rejected by the
NullifierAlreadyUsedcheck
On page load (MyHomePage or ProposalDetailPage):
1. Query Commitments[homeId] from chain -> on-chain commitment
2. Compute poseidon(3, poseidon(1, local homeSecret), identityKeyHash_from_chain)
-> local commitment (paired devices use the on-chain identityKeyHash since they don't have identity_key)
3. If they match -> nothing to do
4. If they differ:
a. Query LastRotationInfo[homeId] from chain -> (block_number, device_account)
(stored on-chain, not reliant on event indexing or non-pruned history)
b. Resolve device_account to label from Devices storage
c. Show prompt: "Voting key was rotated by {device_label}. Re-pair this device to restore voting access."
(link to PairDevicePage)
1. revoke_device(compromised_account): removes signing ability
2. rotate_commitment(new_secret_hash)
: pallet recomputes and replaces commitment in tree
3. Re-pair remaining devices with new pairing QR (flow 2)
4. Compromised device has old homeSecret, but:
- Cannot sign transactions (device seed revoked)
- Old commitment removed from Merkle tree: can't produce valid proofs
- Does NOT have identity_key (never transferred to paired devices): cannot compute
nullifiers or deanonymize votes
- No on-chain encrypted secrets to decrypt: new homeSecret never leaves trusted devices
Any paired device can be promoted to origin. This is useful when the current origin device is being retired, replaced, or is inconvenient for voting.
Current origin device (MyHomePage "Transfer Origin"):
1. Decrypt identityKey via WebAuthn PRF
2. Encode as binary QR payload: [version=0x01, type=0x02 (identity_key), identityKey (32 bytes)]
3. Display QR with strict physical safeguards:
- Auto-dismiss after **10 seconds** (not 30: identity_key is higher sensitivity than home_secret)
- QR is hidden behind a **press-and-hold button**: vanishes the instant the user lets go
- Screen brightness forced to **minimum readable level** to reduce optical reflection range
(shoulder-surfing this QR grants full, permanent deanonymization of all votes)
**Preferred alternative**: use SPAKE2+ or ECDH over local network (Bluetooth/WiFi Direct)
where the QR contains only an ephemeral public key to bootstrap an encrypted channel,
not the identity_key itself. Plaintext QR is an acceptable fallback for environments
where local network pairing is unavailable, but it exposes the most sensitive secret
(full deanonymization capability) optically. The mnemonic is always a recovery path
if the user prefers to avoid optical transfer entirely.
Target device (MyHomePage "Become Origin"):
1. Scan QR from current origin device
2. Parse 34-byte payload: verify version=0x01, type=0x02 -> identityKey
3. Store identityKey encrypted via WebAuthn PRF
4. Set gov-is-origin = true
Current origin device (after transfer confirmed):
1. Delete identityKey from storage
2. Set gov-is-origin = false
3. Show confirmation: "This device is no longer the voting device"
The transfer is atomic from the user's perspective: the new origin can vote immediately. The old origin should delete its copy promptly, but even a delayed deletion is acceptable: the identity_key is the same value, so no double-vote is possible (same nullifiers). The risk is only that two devices temporarily hold the key, widening the attack surface until the old origin completes deletion.
For households where a single voting device is too fragile (origin offline, out of battery, temporarily lost), the same transfer flow can be used to create a secondary origin: a second device that also holds identity_key. The current origin transfers identity_key via QR but does NOT delete its own copy. Both devices can then generate proofs independently.
Security tradeoff: this directly weakens design goal #3 ("isolated identity key"): every additional origin device widens the attack surface for full vote deanonymization. The number of origin devices should be kept minimal (2-3 at most) and each must use WebAuthn PRF for storage. Double-voting is not a risk (same identity_key produces the same nullifiers), but the deanonymization blast radius grows with the number of devices holding the key.
UX caveat: if the gov-voted-proposals cache is out of sync between origins, a user might vote YES on Origin A, then attempt to vote NO on Origin B. The on-chain nullifier check rejects the second vote, but the UX is jarring (unexpected failure). The gov-voted-proposals sync mechanism must be highly reliable when multiple origins are in use: e.g. query Nullifiers on-chain before showing the vote button (as in flow 8), rather than relying solely on the local cache.
Votes are submitted as unsigned extrinsics: no transaction origin, no fees. The ZK proof itself serves as authorisation. This prevents correlating a vote to a device account or home.
Only the origin device can generate ZK proofs (it holds the identity_key). Other devices can initiate a vote, but the origin device must approve and generate the proof.
Voting from origin device (ProposalDetailPage "Cast Your Vote"):
1. Recover homeSecret from localStorage, decrypt identityKey via WebAuthn PRF
2. Ensure voting key is registered on-chain (lazy: if no commitment exists
for this home, call register_commitment automatically before proceeding)
3. Compute nullifier = poseidon(4, identityKey, proposal_id, genesis_hash)
4. Fetch all Merkle leaves and author's identity_key_hash for the proposal
5. Generate ZK proof in browser WASM with vote_direction as public input
The WASM prover rebuilds the Merkle tree from leaves, computes the root
internally, and returns both the proof (128 bytes) and the Merkle root
(32 bytes) it used as a public input. The extrinsic must use this
WASM-computed root: fetching the root separately risks a mismatch
if the tree changed between queries, causing BadProof rejection.
6. Compute PoW: find nonce where hash(zk_proof || pow_nonce) has N leading zero bits
(PoW is bound to the proof bytes, not just the nullifier: an attacker cannot
reuse a valid PoW with a different proof, preventing mempool front-running)
7. Submit cast_vote as unsigned extrinsic with the WASM-computed merkle_root:
- No origin, no fee, no account correlation
- CheckVote extension verifies PoW + ZK proof (two-tier)
- Verifier checks merkle_root is in MerkleRootHistory (or is current)
8. Pallet checks nullifier not already used
9. Records nullifier -> vote direction in Nullifiers storage
Voting from paired device (delegated):
1. User taps "Cast Your Vote" on paired device
2. Paired device displays a QR code encoding: (proposal_id, vote_direction)
3. User scans QR with origin device (or taps a notification if devices are on same network)
4. Origin device shows confirmation: "Vote {yes/no} on proposal {title}?"
5. On confirm, origin device executes the full voting flow above (steps 1-9)
6. Paired device polls Nullifiers storage to detect the vote was cast, updates UI
Public inputs:
merkle_root : current root of the commitment tree
nullifier : poseidon(4, identity_key, proposal_id, genesis_hash)
proposal_id : which proposal is being voted on (passed as a single BN254 scalar, not bit-decomposed)
genesis_hash : first 31 bytes of the chain's genesis block hash, as a BN254 scalar
(truncated to 248 bits for field safety, same as identity_key derivation;
prevents cross-chain/cross-fork replay of proofs)
vote_direction : 0 (nay), 1 (yay), or 2 (abstain), bound to the proof to prevent mempool hijacking
author_identity_key_hash: poseidon(2, author's identity_key), looked up by pallet
Private inputs (witness):
home_secret : the voter's rotatable secret
identity_key : the voter's permanent identity key (origin device only)
merkle_path : Merkle proof that commitment is a leaf in the tree
inverse_diff : multiplicative inverse of (identity_key_hash - author_identity_key_hash)
Constraints:
1. secret_hash = poseidon(1, home_secret)
2. identity_key_hash = poseidon(2, identity_key)
3. commitment = poseidon(3, secret_hash, identity_key_hash)
4. verify_merkle_proof(commitment, merkle_path, merkle_root)
5. nullifier == poseidon(4, identity_key, proposal_id, genesis_hash)
6. vote_direction × (vote_direction - 1) × (vote_direction - 2) == 0 // ternary check: must be 0, 1, or 2
7. diff = identity_key_hash - author_identity_key_hash
8. diff × inverse_diff == 1 // proves diff ≠ 0 (author cannot vote on own proposal)
(Arithmetic circuits have no native ≠ operator. Proving a multiplicative inverse
exists guarantees the difference is non-zero, since 0 has no inverse in a field.)
No rotation circuit is needed. The pallet stores identity_key_hash per home and computes commitments itself from poseidon(3, secret_hash, identity_key_hash). On rotation, the caller submits a new secret_hash and the pallet recomputes the commitment using the stored identity_key_hash. This guarantees the identity_key is preserved without a ZK proof.
Edge case: home transfer after proposal creation: if an author moves out and a new family re-registers the same homeId with a new identity_key_hash, the author constraint (identity_key_hash ≠ author_identity_key_hash) uses the original author's hash (stored on the proposal at creation time). The new family can vote normally. The original author cannot vote (their commitment was removed). No conflict arises: the constraint is checked against the proposal's stored author hash, not the current occupant's hash. The UI should still gracefully handle this: if the current user's identity_key_hash matches the proposal's author_identity_key_hash, disable the vote button with an explanation.
Proof system: Groth16 over BN254. Proofs are exactly 128 bytes (2 G1 + 1 G2 point) with constant verification cost (~1-2ms, 3 pairings). Requires a circuit-specific trusted setup (Phase 2 ceremony on top of an existing Phase 1 Powers of Tau). See the Groth16 setup section for ceremony details.
A v5 transaction extension that authorises bare cast_vote calls by verifying the ZK proof.
fn validate(&self, origin, call, ...) -> ValidateResult {
if origin.as_system_origin_signer().is_some() {
return Ok((ValidTransaction::default(), (), origin)); // signed: passthrough
}
if let Some(Call::cast_vote { proposal_id, vote, nullifier, zk_proof, pow_nonce, merkle_root, .. }) = call.is_sub_type() {
// --- Tier 1: cheap stateless checks (reject garbage fast) ---
// 1. Check nullifier not already used (cheap storage read: first to block replays)
// 2. Check proposal_id is in valid range
// 3. Check nullifier is 32 bytes, non-zero
// 4. Check proof length is exactly 128 bytes (Groth16: 2 G1 + 1 G2)
// 5. Verify PoW: hash(zk_proof || pow_nonce) has N leading zero bits
// Bound to the proof bytes: attacker can't reuse PoW with a different proof
// (N tuned low, e.g. 16 bits ≈ ~65K hashes ≈ <1s on client, <1μs to verify)
// 6. Check proposal exists and is open
// --- Tier 2: expensive ZK verification (only if tier 1 passes) ---
// 7. Verify merkle_root is in MerkleRootHistory (or is current MerkleRoot)
// 8. Get author's identity_key_hash from proposal's author HomeId
// 9. Verify Groth16 proof against (merkle_root, nullifier, proposal_id, genesis_hash, vote, author_identity_key_hash)
// vote_direction and genesis_hash are public inputs: altering them invalidates the proof
let valid = ValidTransaction::with_tag_prefix("governance-vote")
.and_provides((*proposal_id, *nullifier))
.longevity(64)
.propagate(true)
.build()?;
let authorised = frame_system::Origin::<T>::Authorised.into();
return Ok((valid, (), authorised));
}
Ok((ValidTransaction::default(), (), origin))
}Without fees, an attacker could flood the network with invalid proofs. Each Groth16 verification costs ~1-2ms of CPU: at 1000 tx/s, that's 1-2 full cores burned on validation alone. The key insight: fees provide economic backpressure; without them, the system needs computational backpressure instead.
Two-tier validation in CheckVote::validate:
- Tier 1 (cheap, <10μs): nullifier dedup (storage read, first check), format checks, proposal existence, PoW verification. Rejects duplicates, malformed, or spam submissions before any expensive work.
- Tier 2 (expensive, ~1-2ms): Groth16 proof verification. Only reached if all tier-1 checks pass.
Proof-of-work on vote submission: each cast_vote extrinsic includes a pow_nonce. The client must find a nonce such that hash(zk_proof || pow_nonce) has N leading zero bits. The PoW is bound to the proof bytes (not just the nullifier) so an attacker who intercepts a transaction in the mempool cannot compute a valid PoW for a different (garbage) proof while reusing the legitimate nullifier. With N=16, this costs the client ~65K hashes (<1s) but costs the validator <1μs to verify. This creates asymmetric cost: spamming 1000 invalid votes requires ~1000 seconds of client computation but only ~1ms of validator time for PoW checks (before any invalid proof reaches tier 2). The PoW difficulty can be adjusted via a runtime constant.
Additional mitigations:
- Nullifier-based dedup: the transaction pool deduplicates by
(proposal_id, nullifier)tag, so resubmitting the same nullifier is free to reject - Rate limiting at RPC level: the node can limit unsigned extrinsic submissions per IP/connection
- Pool size limits: cap the number of pending unsigned vote extrinsics in the transaction pool
Origin device (ProposalDetailPage on load):
1. Decrypt identityKey via WebAuthn PRF
2. Compute nullifier = poseidon(4, identityKey, proposal_id, genesis_hash)
(cheap: just a hash, no proof generation)
3. Query Nullifiers.getValue(proposal_id, nullifier) from chain
4. If exists -> show "Vote cast", disable vote buttons
Paired device (ProposalDetailPage on load):
1. Check local votedProposals cache (synced from origin device)
2. If proposal_id is in cache -> show "Vote cast", disable vote buttons
3. Otherwise -> show vote button (which delegates to origin device, flow 7)
The votedProposals cache is a list of proposal IDs the home has voted on.
It is updated when the origin device casts a vote and synced to paired devices
via a lightweight mechanism (e.g. shared on-chain storage keyed by homeId,
or local network broadcast). It does not contain nullifiers: only proposal IDs
and vote status: so it cannot be used for deanonymization.
/// Commitment per home: poseidon(3, secret_hash, identity_key_hash).
/// Computed by the pallet from its component hashes.
#[pallet::storage]
pub type Commitments<T: Config> =
StorageMap<_, Blake2_128Concat, HomeId, [u8; 32], OptionQuery>;
/// Identity key hash per home: poseidon(2, identity_key).
/// Set once at registration, never changes. Used by the pallet to
/// recompute commitments on secret rotation without a ZK proof.
/// Does not reveal identity_key (Poseidon is one-way).
#[pallet::storage]
pub type IdentityKeyHashes<T: Config> =
StorageMap<_, Blake2_128Concat, HomeId, [u8; 32], OptionQuery>;
/// Leaf index per home in the Merkle tree. Used for in-place updates
/// on rotation and deregistration.
#[pallet::storage]
pub type CommitmentLeafIndex<T: Config> =
StorageMap<_, Blake2_128Concat, HomeId, u32, OptionQuery>;
/// Last rotation info per home: (block_number, device_account).
/// Stored on-chain so paired devices can detect rotation without relying
/// on event indexing or non-pruned block history.
#[pallet::storage]
pub type LastRotationInfo<T: Config> = StorageMap<
_, Blake2_128Concat, HomeId,
(BlockNumberFor<T>, T::AccountId), // (rotation_block, rotating_device)
OptionQuery,
>;
/// Block number at which a home's commitment was removed. Used to distinguish
/// first-time registration (allowed anytime) from re-registration (blocked
/// while proposals created before the removal are still open).
#[pallet::storage]
pub type CommitmentRemovedAt<T: Config> =
StorageMap<_, Blake2_128Concat, HomeId, BlockNumberFor<T>, OptionQuery>;
/// Minimum `created_at` block among all open proposals. Used for O(1) re-registration
/// checks. Updated when proposals are created or closed. `None` when no proposals are open
/// (re-registration is always allowed in that case).
#[pallet::storage]
pub type EarliestOpenProposalBlock<T: Config> =
StorageValue<_, BlockNumberFor<T>, OptionQuery>;
/// Indexed Merkle tree of all commitments (supports insert, update, and zero-out).
/// Tree depth determines max members (depth 20 = ~1M homes).
#[pallet::storage]
pub type CommitmentTree<T: Config> =
StorageValue<_, IndexedMerkleTree<TREE_DEPTH>, ValueQuery>;
/// Current Merkle root (cached for efficient reads).
#[pallet::storage]
pub type MerkleRoot<T: Config> = StorageValue<_, [u8; 32], ValueQuery>;
/// Recent Merkle roots (ring buffer, last N roots). Allows proofs generated
/// against a slightly stale root to remain valid if the tree changed between
/// proof generation and submission (new registrations, rotations).
/// Without this, users would see random vote failures under load.
#[pallet::storage]
pub type MerkleRootHistory<T: Config> =
StorageValue<_, BoundedVec<[u8; 32], ConstU32<MERKLE_ROOT_HISTORY_SIZE>>, ValueQuery>;
// MERKLE_ROOT_HISTORY_SIZE: e.g. 32 (covers ~32 tree mutations)
/// Vote nullifiers: (proposal_id, nullifier) -> vote direction.
/// Prevents double-voting. Nullifier = poseidon(4, identity_key, proposal_id, genesis_hash).
/// Stable across home_secret rotations since identity_key never changes.
#[pallet::storage]
pub type Nullifiers<T: Config> = StorageDoubleMap<
_, Blake2_128Concat, ProposalId, Blake2_128Concat, [u8; 32],
VoteDirection, OptionQuery,
>;
/// Groth16 verification key for the vote circuit (set once at genesis or via sudo).
/// ~300-500 bytes for ~800 constraints.
#[pallet::storage]
pub type VoteVerificationKey<T: Config> = StorageValue<_, BoundedVec<u8, ConstU32<512>>, OptionQuery>;/// Register a commitment for anonymous voting (first-time setup).
/// The pallet computes the commitment from the two hashes and stores
/// identity_key_hash for future rotations.
pub fn register_commitment(
origin: OriginFor<T>,
secret_hash: [u8; 32], // poseidon(1, home_secret)
identity_key_hash: [u8; 32], // poseidon(2, identity_key)
) -> DispatchResult {
let who = ensure_signed(origin)?;
let home_id = Self::ensure_active_member(&who)?;
ensure!(!Commitments::<T>::contains_key(home_id), Error::AlreadyRegistered);
// Re-registration: block while any proposal created before removal is still open.
// First-time registration is always allowed (supports late registration).
if let Some(removed_at) = CommitmentRemovedAt::<T>::get(home_id) {
if let Some(earliest_open) = EarliestOpenProposalBlock::<T>::get() {
ensure!(
earliest_open >= removed_at,
Error::ReregistrationBlockedWhilePreRemovalProposalsOpen,
);
}
// None => no open proposals => re-registration allowed
}
let commitment = Self::poseidon_commitment(&secret_hash, &identity_key_hash);
let leaf_index = Self::merkle_insert(&commitment);
Commitments::<T>::insert(home_id, commitment);
IdentityKeyHashes::<T>::insert(home_id, identity_key_hash);
CommitmentLeafIndex::<T>::insert(home_id, leaf_index);
CommitmentRemovedAt::<T>::remove(home_id); // clean up re-registration guard
Self::deposit_event(Event::CommitmentRegistered { home_id });
Ok(())
}
/// Rotate a home's voting commitment (compromise response).
/// The pallet recomputes the new commitment from the caller's new secret_hash
/// and the stored identity_key_hash. No ZK proof needed: the pallet
/// enforces identity_key continuity by construction.
pub fn rotate_commitment(
origin: OriginFor<T>,
new_secret_hash: [u8; 32], // poseidon(1, new_home_secret)
) -> DispatchResult {
let who = ensure_signed(origin)?;
let home_id = Self::ensure_active_member(&who)?;
ensure!(Commitments::<T>::contains_key(home_id), Error::NoCommitment);
// 1. Recompute commitment using stored identity_key_hash
let identity_key_hash = IdentityKeyHashes::<T>::get(home_id)
.ok_or(Error::NoCommitment)?;
let new_commitment = Self::poseidon_commitment(&new_secret_hash, &identity_key_hash);
// 2. Update Merkle tree: replace old leaf with new commitment
let leaf_index = CommitmentLeafIndex::<T>::get(home_id)
.ok_or(Error::NoCommitment)?;
Self::merkle_update(leaf_index, &new_commitment);
Commitments::<T>::insert(home_id, new_commitment);
LastRotationInfo::<T>::insert(home_id, (
frame_system::Pallet::<T>::block_number(),
who,
));
Self::deposit_event(Event::CommitmentRotated { home_id });
Ok(())
}
/// Remove a home's commitment from the Merkle tree (deregistration or removal).
/// Callable by the home's own device or by governance (e.g. when a home is removed
/// from the community). Zeros out the Merkle leaf so the home can no longer vote.
pub fn remove_commitment(
origin: OriginFor<T>,
home_id: HomeId,
) -> DispatchResult {
// Either the home's own member or governance origin
Self::ensure_authorized_for_home(origin, home_id)?;
ensure!(Commitments::<T>::contains_key(home_id), Error::NoCommitment);
let leaf_index = CommitmentLeafIndex::<T>::get(home_id)
.ok_or(Error::NoCommitment)?;
Self::merkle_update(leaf_index, &[0u8; 32]); // zero out leaf
Commitments::<T>::remove(home_id);
IdentityKeyHashes::<T>::remove(home_id);
CommitmentLeafIndex::<T>::remove(home_id);
CommitmentRemovedAt::<T>::insert(home_id, frame_system::Pallet::<T>::block_number());
// Purge PII from the Homes struct: data minimization. The community
// no longer needs this member's personal information after removal.
// (name and encrypted_phone are fields on the Home struct, zeroed here)
Self::deposit_event(Event::CommitmentRemoved { home_id });
Ok(())
}
/// Reset a home's devices. Callable by governance origin only (e.g. board multisig
/// or referendum). Revokes all current devices and registers a new one.
/// Used for recovery when all devices are lost or compromised.
pub fn reset_home_devices(
origin: OriginFor<T>,
home_id: HomeId,
new_device_account: T::AccountId,
) -> DispatchResult {
T::GovernanceOrigin::ensure_origin(origin)?;
Self::revoke_all_devices(home_id);
Self::add_device_unchecked(home_id, new_device_account);
Self::deposit_event(Event::HomeDevicesReset { home_id });
Ok(())
}
/// Cast an anonymous vote (bare extrinsic, authorised by CheckVote).
/// VoteDirection is mapped to a BN254 scalar for the circuit: Nay = 0, Yay = 1, Abstain = 2.
/// The frontend WASM prover must use these exact values when formatting public
/// inputs for the Groth16 proof, matching the circuit's ternary constraint
/// (vote_direction × (vote_direction - 1) × (vote_direction - 2) == 0).
pub fn cast_vote(
origin: OriginFor<T>,
proposal_id: ProposalId,
vote: VoteDirection, // Nay = 0, Yay = 1, Abstain = 2 as circuit scalar
nullifier: [u8; 32], // poseidon(4, identity_key, proposal_id, genesis_hash)
zk_proof: BoundedVec<u8, ConstU32<128>>, // Groth16 proof (2 G1 + 1 G2, BN254)
pow_nonce: u64, // PoW: hash(zk_proof || pow_nonce) has N leading zeros
merkle_root: [u8; 32], // root the proof was generated against (from WASM prover)
) -> DispatchResult {
T::AuthorizedOrigin::ensure_origin(origin)?; // set by CheckVote extension
// Verify proposal is open
// Check nullifier not already used
// Verify merkle_root is current or in MerkleRootHistory
// Verify ZK proof against (merkle_root, nullifier, proposal_id, genesis_hash, vote, author_identity_key_hash)
// vote_direction and genesis_hash are public inputs: tampering invalidates the proof
// Record nullifier -> vote direction
// Update tally
}| Key | Value | Set by | Devices |
|---|---|---|---|
gov-device-seed |
AES-GCM encrypted (WebAuthn PRF key) or passphrase-encrypted fallback | Registration / Pairing | All |
gov-home-secret |
Hex string (32 bytes) | Registration / Pairing | All |
gov-identity-key |
AES-GCM encrypted (WebAuthn PRF key). Never stored in plaintext. | Registration (from mnemonic) | Origin only |
gov-is-origin |
true |
Registration | Origin only |
gov-voted-proposals |
JSON array of proposal IDs | Updated after each vote, synced to paired devices | All |
gov-credential-id |
Base64 WebAuthn credential ID | Registration | All |
gov-community-private-key |
Hex string (32-byte X25519 private key) | Pairing (included in QR payload) | All |
gov-committee-private-key |
Hex string (32-byte X25519 private key) | Committee pairing QR | Committee only |
Encryption fallback: when WebAuthn PRF is unavailable (older browsers, unsupported authenticators), secrets are encrypted with a user-chosen passphrase via PBKDF2 (100K iterations) + AES-256-GCM. The UI must enforce a minimum passphrase length (12+ characters) and warn that this is weaker than hardware-bound WebAuthn. For gov-identity-key on the origin device, WebAuthn PRF should be strongly recommended: the fallback is acceptable for gov-device-seed on paired devices (lower sensitivity) but is a meaningful downgrade for the origin's identity key.
The commitment tree is an indexed Merkle tree using Poseidon hash. Unlike the append-only trees in Semaphore/Tornado Cash, this tree supports in-place updates (for rotation) and zero-outs (for deregistration). Each home's leaf position is tracked in CommitmentLeafIndex.
- Depth: 20 (supports up to ~1M commitments)
- Storage: sparse representation: only non-zero subtree roots stored on-chain. For
ncommitments, stores O(n × depth) nodes rather than2^depth. - Insert: O(depth) Poseidon hashes to update the path from new leaf to root
- Update (rotation): O(depth) hashes: replace leaf value, recompute path
- Zero-out (deregistration): O(depth) hashes: set leaf to zero, recompute path
The Merkle root is cached in MerkleRoot storage for efficient reads during proof verification.
Poseidon is ZK-friendly (few constraints in a circuit) but not free to compute natively. Each Merkle insert/update requires depth (20) Poseidon hashes. Commitment computation adds 3 more (two for component hashes, one for the pair). Benchmarks should target:
register_commitment: ~23 Poseidon hashes (3 commitment + 20 Merkle path)rotate_commitment: ~21 Poseidon hashes (1 commitment + 20 Merkle path)remove_commitment: ~20 Poseidon hashes (Merkle path only)
These should be benchmarked with frame_benchmarking to set accurate weights. Poseidon over BN254 scalar field is estimated at ~10-50μs per hash in native execution, so the total per-extrinsic cost is ~0.2-1.2ms: well within block weight limits.
Vote circuit (the only circuit):
- 6 public inputs:
merkle_root,nullifier,proposal_id,genesis_hash,vote_direction,author_identity_key_hash - 4 private inputs:
home_secret,identity_key,merkle_path(20 siblings for depth-20 tree),inverse_diff(for author ≠ voter check) - ~800 constraints (Poseidon hashes + Merkle verification + ternary check + inverse check)
No rotation circuit is needed: the pallet enforces identity_key continuity by computing commitments itself (see rotation flow).
- Setup: two-phase trusted setup. Phase 1 (Powers of Tau) is universal and reusable: use an existing large-scale ceremony (e.g. Hermez or Zcash's). Phase 2 is circuit-specific and must be run once for the vote circuit. Only one honest participant is needed for soundness.
- Proof size: 128 bytes (constant: 2 G1 + 1 G2 point on BN254)
- Prove time: ~1-2s in browser WASM (~800 constraints)
- Verify time: ~1-2ms (constant, 3 pairings)
- Library: arkworks with ark-groth16 (Rust, compiles to WASM for browser + native for pallet)
The verification key is stored on-chain (~300-500 bytes). The proving key (~50-100KB for ~800 constraints) is bundled with the frontend and loaded on demand.
Phase 2 ceremony: run with 10-20 community participants using snarkjs or arkworks tooling. Each participant contributes randomness; the final parameters are published. If the circuit changes (new constraints, different Merkle depth), a new Phase 2 ceremony is required: but the circuit is designed to be stable (rotation and re-registration are handled by the pallet, not the circuit). Phase 1 parameters are never invalidated.
Why Groth16 over Plonk: the system has exactly one circuit that is stable by design. Groth16's per-circuit setup cost is amortized over every vote. In return: 128-byte proofs (vs ~400-600), ~1-2ms verification (vs ~3-5ms) on the hottest path (unsigned extrinsics validated by every node), and faster browser proving. The universal setup of Plonk is unnecessary here.
| Threat | Impact | Mitigation |
|---|---|---|
| One device seed stolen | Attacker can sign transactions as that device | Revoke device via revoke_device from another device |
| Home secret stolen | Attacker can produce valid membership proofs | Rotate commitment; old commitment removed from tree, can't produce new valid proofs. Votes already cast remain (nullifiers recorded). |
| Paired device stolen (has home_secret only) | Attacker can produce membership proofs but cannot compute nullifiers, generate ZK vote proofs, or deanonymize past votes | Revoke device + rotate commitment. Attacker never had identity_key: vote anonymity is preserved. |
| Identity key stolen (origin device compromised) | Full deanonymization: attacker pre-computes nullifiers for all proposals, matches against on-chain Nullifiers storage to learn both participation and vote direction. Permanent, since the key never rotates. |
identity_key never leaves the origin device digitally. Attack surface is limited to: origin device compromise, or physical theft of the mnemonic backup. Mitigate with WebAuthn PRF (hardware-bound encryption), secure mnemonic storage, and treating the origin device as the high-value target. The attacker cannot cast votes without a valid commitment in the Merkle tree. |
| Device seed + home secret + identity key stolen (full compromise) | Full access to one device + home's vote + deanonymization | Revoke device + rotate commitment + re-pair remaining devices. Attacker loses signing and proving ability. Restore identity_key from mnemonic on new origin device. |
| Secret rotation to get a second vote | Not possible | Nullifiers are derived from identity_key, which never rotates. The same proposal always produces the same nullifier regardless of how many times home_secret is rotated. The NullifierAlreadyUsed check blocks any second vote. |
| Re-registration to get a second vote | Not possible while pre-removal proposals are open | register_commitment blocks re-registration while any proposal created before the removal is still open. First-time registration is always allowed (late registration). The home can vote on proposals created after their removal. |
| Late registration | Can vote on all open proposals | Merkle tree is live: new commitments are immediately eligible. No snapshots needed. |
| Author tries to vote on own proposal | ZK proof rejected | Circuit constraint: identity_key_hash ≠ author_identity_key_hash. Checked against the author's permanent identity_key_hash (not their commitment), so it survives secret rotation. |
| Home removed but commitment lingers | Removed home could still vote | remove_commitment zeros out the Merkle leaf when a home is removed from the community. Governance can call this directly. |
| Full device takeover (attacker revokes all devices, adds own) | Attacker controls the home's devices | Owner contacts community board → governance calls reset_home_devices to revoke attacker's devices and register owner's new device. Owner restores identity_key from mnemonic. Attacker cannot prevent governance action. |
| Device holds stale secret after rotation | Cannot vote until re-paired | Detected on page load (local commitment ≠ on-chain). User prompted to re-pair. |
| Flood of invalid ZK proofs | CPU burn on validators | Two-tier validation: cheap tier-1 checks + PoW (~1μs) reject garbage before expensive tier-2 Groth16 verification (~1-2ms). Nullifier dedup + pool size limits + RPC rate limiting. |
| Vote coercion (proving how you voted) | Voter can demonstrate their vote to a coercer using identity_key + deterministic nullifier |
No coercion resistance. Nullifiers are deterministic and vote direction is public in Nullifiers storage. A voter with identity_key can prove to anyone how they voted. Receipt-freeness would require randomized nullifiers or re-encryption techniques: out of scope for current design. Documented as a known limitation. |
| Nullifier front-running (mempool DoS) | Attacker intercepts a vote from the mempool, copies the nullifier, and submits a garbage proof with a new PoW to displace the legitimate tx | PoW is bound to the proof bytes (hash(zk_proof || pow_nonce)), not just the nullifier. An attacker cannot compute a valid PoW for a different proof without re-doing the work. The transaction pool deduplicates by (proposal_id, nullifier) tag, so the first-seen valid PoW wins the slot. An attacker who can compute the nullifier already has identity_key and can vote directly: no grief-only vector. |
| Cross-network replay | A proof generated on testnet (or post-fork) is replayed on mainnet for the same proposal_id |
genesis_hash (first 31 bytes) is included in the nullifier derivation and as a circuit public input. The pallet supplies the local chain's genesis hash during verification: a proof from a different network produces a different nullifier and fails verification. |
| Merkle root changes between proof generation and submission | Proof becomes invalid (generated against a stale root) | The WASM prover returns the root it used as a public input alongside the proof. The extrinsic submits this root (not a separately-fetched one), guaranteeing consistency. MerkleRootHistory ring buffer accepts any of the last N roots, so a slightly stale root is still valid. Client retries with fresh leaves if the root is too old. |
| On-chain scraping of names or phones | Attacker queries chain storage to read PII | All PII is encrypted with X25519 hybrid encryption. Only the public key is on-chain: the private key is never in the frontend bundle or on-chain. An attacker with full chain access and frontend source sees only ciphertext and ephemeral public keys. Decryption requires the private key, which is distributed only to authorised parties (members for names, committee for phones). |
| Community private key leaked (member removed for cause) | Attacker can decrypt all names encrypted with the current community public key | The removed member loses app access (primary decryption path). Names are socially public within the community: the removed member already knew them. Community key is rotated infrequently (annually or on breach), not per-removal. Raw chain-state decryption requires technical effort unlikely to be exerted for already-known names. |
| PII exposed before membership approval | Applicant's name readable by all members before committee approves | Accepted tradeoff: applicant encrypts PII in the apply extrinsic. Any member holding the community private key can decrypt the name during the approval window. Names are low-sensitivity (needed for the directory) and the window is short. Phone numbers are protected: only committee members hold the committee private key. |
| Ciphertext replay across homes | Attacker copies encrypted phone from HomeId=1 to HomeId=3 to impersonate | HKDF info uses purpose-only separation ("pii-phone") so a copied ciphertext would still decrypt to the same plaintext. The actual mitigation is on the write side: only the committee (via rotate_encrypted_phones) and the home itself (via apply for its initial submission) can write a home's encrypted_phone field; an attacker cannot simply paste another home's ciphertext into chain storage. Each encryption uses a unique ephemeral X25519 keypair, so identical plaintexts still produce different ciphertexts on the wire. |
| Committee private key leaked (committee member removed for cause) | Attacker can decrypt all phone numbers encrypted with the current committee public key | Full rotation: committee generates new keypair, publishes new public key on-chain, re-encrypts all phones via rotate_encrypted_phones batch extrinsic, distributes new private key to remaining committee members. Old private key becomes useless after re-encryption. |
| Paired device stolen (has community private key) | Attacker can decrypt all resident names | Revoke device + rotate community keypair if the name exposure is a concern. The attacker cannot decrypt phone numbers (no committee private key) and cannot deanonymize votes (no identity_key). |
A hash ratchet (epoch_secret[t] = hash(epoch_secret[t-1])) could scope nullifiers to epochs: nullifier = hash(identity_key || proposal_id || epoch_secret[epoch_of(proposal)]). Once all proposals in an epoch close, the origin device deletes that epoch's secret. The one-way ratchet prevents recovering past secrets from a future leak, giving forward secrecy for closed epochs. Not implemented because: epoch_secrets for open proposals must be retained (partial protection), the mnemonic cannot recover epoch_secrets (origin device loss = lost votes), and the circuit gains complexity (epoch_id public input + epoch_secret verification).
An escape hatch for suspected partial identity_key leakage (e.g. memory snapshot, side channel): a one-time ZK proof demonstrates old_identity_key → new_identity_key while preserving nullifier accountability. The pallet updates identity_key_hash, and nullifiers for already-voted proposals are re-derived and stored. Expensive (circuit must iterate over voted proposals) and complex, but provides a recovery path without waiting for proposals to close. The architecture does not preclude adding this later.
At scale, each vote requiring a separate Groth16 proof becomes expensive in block space. Recursive proof composition (e.g. wrapping Groth16 proofs in a Plonk/Nova aggregation layer) could batch N vote proofs into a single aggregate proof, reducing per-vote on-chain verification cost to ~O(1). Not needed at current scale but worth considering beyond ~10K votes per proposal.