nullifiers leave the client: spent checks look up UsedNullifiers by key #68

Closed
opened 2026-09-16 17:53:30 +00:00 by grenade · 0 comments
Owner

Parent: #9

The leak (shipped in 0.1.0 and 0.2.0)

wormhole_summary (crates/wallet-app/src/commands.rs) computes the nullifier of every unspent leaf that pays the wallet's wormhole address and asks the node about each one with substrate::wormhole::nullifier_spent, a Wormhole::UsedNullifiers lookup by key. That map is Blake2_128Concat, so the key is blake2_128(n) ‖ n: the request hands the node the nullifier itself. Exits publish their nullifiers on chain, so whoever runs the node can later match an exit to the connection that asked about it, and that connection also asked for the balances of the wallet's transparent accounts. The accounts page runs this automatically for every open wallet that carries a phrase, as soon as the chain connects, so any 0.2.0 install that opened mainnet has sent its unspent nullifiers to wss://quantus.blackbeard.observer (bob), and on Planck through the proxy to Quantus's nodes.

quantus/extension hit the same thing and fixed it (packages/extension-base/src/background/Wormhole.ts, NULLIFIER_BUCKETS_MIN): read whole buckets of the map by the first byte of blake2_128(n) with state_getKeysPaged, pad the set of buckets with random ones to at least 16, ask in shuffled order, and check membership locally. The node learns which buckets were read, never which entry mattered; one deposit hides among a sixteenth of all spends.

The rest of the shielded read is already clean: leaves_paying reads ZkTree::Leaves by index across the whole range, so the node never learns which leaves are the wallet's, and no observer is asked about wormhole addresses.

Scope

  • Replace per-key nullifier_spent with a bucket read of Wormhole::UsedNullifiers (the extension's rule: at least 16 buckets, random padding, shuffled), membership checked in Rust.
  • A test that fails if any storage key built for the spent check ends with a nullifier of ours, so the per-key lookup cannot come back.
  • Record the rule in doc/threat-model.md under the wormhole: nullifiers, secrets and the leaf indices being spent never leave the client.
  • Release it as a patch.

Acceptance

On mainnet, the spent check for a wallet with leaves sends only state_getKeysPaged requests on bucket prefixes (logged request list in the test), the shielded balance is unchanged from 0.2.0's answer, and the leak test above passes.

Parent: #9 ## The leak (shipped in 0.1.0 and 0.2.0) `wormhole_summary` (`crates/wallet-app/src/commands.rs`) computes the nullifier of every unspent leaf that pays the wallet's wormhole address and asks the node about each one with `substrate::wormhole::nullifier_spent`, a `Wormhole::UsedNullifiers` lookup by key. That map is `Blake2_128Concat`, so the key is `blake2_128(n) ‖ n`: the request hands the node the nullifier itself. Exits publish their nullifiers on chain, so whoever runs the node can later match an exit to the connection that asked about it, and that connection also asked for the balances of the wallet's transparent accounts. The accounts page runs this automatically for every open wallet that carries a phrase, as soon as the chain connects, so any 0.2.0 install that opened mainnet has sent its unspent nullifiers to `wss://quantus.blackbeard.observer` (bob), and on Planck through the proxy to Quantus's nodes. quantus/extension hit the same thing and fixed it (`packages/extension-base/src/background/Wormhole.ts`, `NULLIFIER_BUCKETS_MIN`): read whole buckets of the map by the first byte of `blake2_128(n)` with `state_getKeysPaged`, pad the set of buckets with random ones to at least 16, ask in shuffled order, and check membership locally. The node learns which buckets were read, never which entry mattered; one deposit hides among a sixteenth of all spends. The rest of the shielded read is already clean: `leaves_paying` reads `ZkTree::Leaves` by index across the whole range, so the node never learns which leaves are the wallet's, and no observer is asked about wormhole addresses. ## Scope - Replace per-key `nullifier_spent` with a bucket read of `Wormhole::UsedNullifiers` (the extension's rule: at least 16 buckets, random padding, shuffled), membership checked in Rust. - A test that fails if any storage key built for the spent check ends with a nullifier of ours, so the per-key lookup cannot come back. - Record the rule in `doc/threat-model.md` under the wormhole: nullifiers, secrets and the leaf indices being spent never leave the client. - Release it as a patch. ## Acceptance On mainnet, the spent check for a wallet with leaves sends only `state_getKeysPaged` requests on bucket prefixes (logged request list in the test), the shielded balance is unchanged from 0.2.0's answer, and the leak test above passes.
grenade added the child label 2026-09-16 17:53:36 +00:00
grenade referenced this issue from a commit 2026-09-16 18:01:13 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: blackbeard/wallet#68