diff --git a/crates/wallet-app/src/commands.rs b/crates/wallet-app/src/commands.rs index 18bed67..835fc99 100644 --- a/crates/wallet-app/src/commands.rs +++ b/crates/wallet-app/src/commands.rs @@ -972,18 +972,23 @@ pub async fn wormhole_summary( .map_err(|e| WalletError::Internal(e.to_string()))?; } - // Which of the unspent ones have been spent since. + // Which of the unspent ones have been spent since. Decided here from + // whole buckets of UsedNullifiers; no nullifier is sent (#68). let leaves = cache .leaves(&chain, &address) .map_err(|e| WalletError::Internal(e.to_string()))?; - let mut newly_spent = Vec::new(); - for l in leaves.iter().filter(|l| !l.spent) { - let n = nullifier(keys.secret(), l.transfer_count) - .map_err(|e| WalletError::Internal(e.to_string()))?; - if zk::nullifier_spent(&conn, &n).await.map_err(chain_error)? { - newly_spent.push(l.index); - } - } + let unspent: Vec<&ShieldedLeaf> = leaves.iter().filter(|l| !l.spent).collect(); + let ours = unspent + .iter() + .map(|l| nullifier(keys.secret(), l.transfer_count)) + .collect::, _>>() + .map_err(|e| WalletError::Internal(e.to_string()))?; + let spent = zk::spent_among(&conn, &ours).await.map_err(chain_error)?; + let newly_spent: Vec = unspent + .iter() + .zip(spent) + .filter_map(|(l, s)| s.then_some(l.index)) + .collect(); if !newly_spent.is_empty() { cache .mark_spent(&chain, &address, &newly_spent) diff --git a/crates/wallet-data/Cargo.toml b/crates/wallet-data/Cargo.toml index e0b99ac..67a1add 100644 --- a/crates/wallet-data/Cargo.toml +++ b/crates/wallet-data/Cargo.toml @@ -14,6 +14,7 @@ wallet-core.workspace = true thiserror.workspace = true zeroize.workspace = true getrandom.workspace = true +blake2.workspace = true qp-rusty-crystals-dilithium.workspace = true qp-rusty-crystals-hdwallet.workspace = true qp-poseidon-core.workspace = true @@ -39,4 +40,3 @@ async-trait.workspace = true [dev-dependencies] tracing-subscriber.workspace = true tokio.workspace = true -blake2.workspace = true diff --git a/crates/wallet-data/src/substrate/wormhole.rs b/crates/wallet-data/src/substrate/wormhole.rs index bf84cd8..19701cb 100644 --- a/crates/wallet-data/src/substrate/wormhole.rs +++ b/crates/wallet-data/src/substrate/wormhole.rs @@ -6,8 +6,20 @@ //! asset_id, amount}`, in the clear. Nothing on chain indexes leaves by //! recipient, so the wallet scans the map once from index 0 to //! `LeafCount`, keeps what pays its address, and afterwards scans only what -//! was appended. A leaf is spent when its nullifier, which only the holder -//! of the secret can compute, is in `Wormhole::UsedNullifiers`. +//! was appended. Reading every index is also what keeps the node from +//! learning which leaves are the wallet's. +//! +//! A leaf is spent when its nullifier, which only the holder of the secret +//! can compute, is in `Wormhole::UsedNullifiers`. **A nullifier never leaves +//! the client** (blackbeard/wallet #68). The map is `Blake2_128Concat`, so a +//! key is `blake2_128(n) ‖ n`: looking one up hands the node the nullifier, +//! and an exit publishes its nullifier on chain, so the node could name the +//! exit and the wallet behind it. Instead the wallet reads whole buckets of +//! the map (keys sharing the first byte of `blake2_128(n)`), pads the buckets +//! it needs with random others to at least [`MIN_NULLIFIER_BUCKETS`], asks +//! in shuffled order, and checks membership here. The node learns which +//! buckets were read, never which entry mattered. The same rule as +//! quantus/extension's `NULLIFIER_BUCKETS_MIN`. use subxt::dynamic; use subxt::ext::scale_value::{self, At, Value}; @@ -114,34 +126,117 @@ pub async fn leaves_paying( Ok(out) } -/// Whether a nullifier is in `Wormhole::UsedNullifiers`: the leaf it -/// belongs to has been spent. -pub async fn nullifier_spent( +/// Buckets of `Wormhole::UsedNullifiers` read per check, at least: one +/// deposit hides among a sixteenth of all spends rather than a 256th. +pub const MIN_NULLIFIER_BUCKETS: usize = 16; + +/// Keys per `state_getKeysPaged` page. +const KEYS_PAGE: u32 = 1000; + +/// The bucket a nullifier's `UsedNullifiers` key falls in: the first byte of +/// `blake2_128(n)`, the hash the map is keyed with. +pub fn bucket_of(nullifier: &[u8; 32]) -> u8 { + use blake2::digest::{Update, VariableOutput}; + let mut h = blake2::Blake2bVar::new(16).expect("16 is a valid blake2b output size"); + h.update(nullifier); + let mut out = [0u8; 16]; + h.finalize_variable(&mut out) + .expect("the buffer is the output size"); + out[0] +} + +/// The buckets to read for `ours`: every bucket one of them falls in, padded +/// with random others to at least [`MIN_NULLIFIER_BUCKETS`], in shuffled +/// order so the ones that matter do not come first. `random` supplies +/// uniform bytes; pure, so the rule is testable. +pub fn plan_buckets(ours: &[[u8; 32]], mut random: impl FnMut() -> u8) -> Vec { + let mut set: Vec = Vec::with_capacity(MIN_NULLIFIER_BUCKETS); + for n in ours { + let b = bucket_of(n); + if !set.contains(&b) { + set.push(b); + } + } + while set.len() < MIN_NULLIFIER_BUCKETS { + let b = random(); + if !set.contains(&b) { + set.push(b); + } + } + // Fisher-Yates with rejection sampling, so the order is unbiased. + for i in (1..set.len()).rev() { + let bound = (i + 1) as u16; + let j = loop { + let r = u16::from(random()); + if r < 256 - (256 % bound) { + break (r % bound) as usize; + } + }; + set.swap(i, j); + } + set +} + +/// The storage prefix of one bucket: the map's root plus the bucket byte. +pub fn bucket_prefix(root: &[u8], bucket: u8) -> Vec { + let mut p = Vec::with_capacity(root.len() + 1); + p.extend_from_slice(root); + p.push(bucket); + p +} + +/// The nullifier at the end of a `UsedNullifiers` key. +fn nullifier_from_key(root_len: usize, key: &[u8]) -> Option<[u8; 32]> { + if key.len() != root_len + 16 + 32 { + return None; + } + key[root_len + 16..].try_into().ok() +} + +/// A uniformly random byte from the operating system. +fn os_random_byte() -> u8 { + let mut b = [0u8; 1]; + getrandom::fill(&mut b).expect("the operating system has randomness"); + b[0] +} + +/// For each of `ours`, whether it is in `Wormhole::UsedNullifiers`, decided +/// here from whole buckets read at the node's best block. No request carries +/// a nullifier: each is a bucket prefix, the map's root and one byte. +pub async fn spent_among( conn: &ChainConnection, - nullifier: &[u8; 32], -) -> Result { - let addr = dynamic::storage( + ours: &[[u8; 32]], +) -> Result, ChainError> { + if ours.is_empty() { + return Ok(Vec::new()); + } + let root = conn.client.storage().address_root_bytes(&dynamic::storage( "Wormhole", "UsedNullifiers", - vec![Value::from_bytes(nullifier)], - ); - let v = conn - .client - .storage() - .at_latest() - .await - .map_err(|e| ChainError::Rpc(e.to_string()))? - .fetch(&addr) - .await - .map_err(|e| ChainError::Rpc(e.to_string()))?; - Ok(match v { - None => false, - Some(v) => v - .to_value() - .map_err(|e| ChainError::Other(e.to_string()))? - .as_bool() - .unwrap_or(true), - }) + Vec::::new(), + )); + let mut used = std::collections::HashSet::new(); + for bucket in plan_buckets(ours, os_random_byte) { + let prefix = bucket_prefix(&root, bucket); + let mut start: Option> = None; + loop { + let keys = conn + .rpc + .state_get_keys_paged(&prefix, KEYS_PAGE, start.as_deref(), None) + .await + .map_err(|e| ChainError::Rpc(e.to_string()))?; + for key in &keys { + if let Some(n) = nullifier_from_key(root.len(), key) { + used.insert(n); + } + } + if (keys.len() as u32) < KEYS_PAGE { + break; + } + start = keys.last().cloned(); + } + } + Ok(ours.iter().map(|n| used.contains(n)).collect()) } /// The shielded balance: unspent leaves summed, as base units. @@ -176,6 +271,73 @@ fn leaf_index_from_key(key: &[u8]) -> Option { mod tests { use super::*; + fn nullifiers(k: u8) -> Vec<[u8; 32]> { + (0..k) + .map(|i| [i.wrapping_mul(37).wrapping_add(5); 32]) + .collect() + } + + #[test] + fn buckets_cover_ours_are_padded_to_sixteen_and_shuffled() { + let ours = nullifiers(3); + let mut counter = 0u8; + let plan = plan_buckets(&ours, || { + counter = counter.wrapping_add(97); + counter + }); + assert!(plan.len() >= MIN_NULLIFIER_BUCKETS); + let mut dedup = plan.clone(); + dedup.sort_unstable(); + dedup.dedup(); + assert_eq!(dedup.len(), plan.len(), "no bucket twice"); + for n in &ours { + assert!(plan.contains(&bucket_of(n))); + } + // More buckets of ours than the minimum are all read, with no padding. + let many = nullifiers(60); + let needed: std::collections::HashSet = many.iter().map(bucket_of).collect(); + let plan = plan_buckets(&many, os_random_byte); + assert_eq!(plan.len(), needed.len().max(MIN_NULLIFIER_BUCKETS)); + // Over many plans ours do not always come first. + let first_is_ours = (0..64) + .filter(|_| { + let plan = plan_buckets(&ours[..1], os_random_byte); + plan[0] == bucket_of(&ours[0]) + }) + .count(); + assert!( + first_is_ours < 32, + "{first_is_ours} of 64 plans led with ours" + ); + } + + /// The regression guard for #68: nothing the spent check asks for + /// contains one of our nullifiers, and every request is a bucket prefix. + #[test] + fn no_request_carries_a_nullifier() { + let root = [0xabu8; 32]; + let ours = nullifiers(5); + for bucket in plan_buckets(&ours, os_random_byte) { + let prefix = bucket_prefix(&root, bucket); + assert_eq!(prefix.len(), root.len() + 1); + for n in &ours { + assert!( + !prefix.windows(32).any(|w| w == n), + "a request carries a nullifier" + ); + } + } + // And the map's key layout is what the parser expects. + let n = ours[0]; + let mut key = root.to_vec(); + let mut h = [0u8; 16]; + h[0] = bucket_of(&n); + key.extend_from_slice(&h); + key.extend_from_slice(&n); + assert_eq!(nullifier_from_key(root.len(), &key), Some(n)); + assert_eq!(nullifier_from_key(root.len(), &key[..70]), None); + } + #[test] fn the_leaf_index_is_the_last_eight_bytes_of_the_key() { let mut key = vec![0xaa; 32]; @@ -210,8 +372,34 @@ mod tests { .find(|l| l.index == 91928) .expect("leaf 91928 pays bob"); assert_eq!((l.transfer_count, l.amount), (30, 310_000_000_000)); - // A nullifier nobody has used. - assert!(!nullifier_spent(&conn, &[7u8; 32]).await.unwrap()); + // The bucket read agrees with the chain: a nullifier taken from a + // bucket is spent, one nobody has used is not, and the chain's own + // key hash puts the real one in the bucket it was read from. + let root = conn.client.storage().address_root_bytes(&dynamic::storage( + "Wormhole", + "UsedNullifiers", + Vec::::new(), + )); + let mut real = None; + for bucket in 0..=255u8 { + let keys = conn + .rpc + .state_get_keys_paged(&bucket_prefix(&root, bucket), 1, None, None) + .await + .unwrap(); + if let Some(n) = keys.first().and_then(|k| nullifier_from_key(root.len(), k)) { + assert_eq!(bucket_of(&n), bucket, "blake2_128(n)[0] is the bucket"); + real = Some(n); + break; + } + } + match real { + Some(n) => assert_eq!( + spent_among(&conn, &[n, [7u8; 32]]).await.unwrap(), + [true, false] + ), + None => eprintln!("no exit has spent a nullifier on this chain yet"), + } eprintln!("leaf count {count}; bob's leaves in the window: {leaves:?}"); // The whole tree: every reward bob's miner earned is a leaf, so the diff --git a/doc/threat-model.md b/doc/threat-model.md index 4c68e12..373c1e6 100644 --- a/doc/threat-model.md +++ b/doc/threat-model.md @@ -123,6 +123,34 @@ what was signed (the review is over local bytes), and cannot take funds: the worst is a stale or false view, and the UI shows the finalized height beside the best so the lag is visible. +### The wormhole: what may never reach a node + +Mining rewards and other wormhole deposits are leaves in `ZkTree::Leaves`, +readable by anyone with the recipient and amount in the clear, and an exit +publishes the nullifier of the leaf it spends. So the link between a +wallet and its exits is exactly what a node must not be handed. + +- **Nullifiers never leave the client** (#68). The spent check reads whole + buckets of `Wormhole::UsedNullifiers` (keys sharing the first byte of + `blake2_128(n)`), at least 16 of them with random padding, in shuffled + order, and decides membership in Rust + (`crates/wallet-data/src/substrate/wormhole.rs`, `spent_among`, + `MIN_NULLIFIER_BUCKETS`). The test `no_request_carries_a_nullifier` + guards it. 0.1.0 and 0.2.0 looked each nullifier up by key, which put the + nullifier in the request; 0.2.1 fixed it. +- **The wallet's leaves are not named either.** The leaf scan reads every + index of `ZkTree::Leaves` in pages (`leaves_paying`), so the node cannot + tell which leaves pay the wallet, and no indexer is asked about wormhole + addresses. +- **Spending (#46, #47) must keep the rule**: the Merkle path to the leaf + being spent may not be requested for that leaf alone + (`zkTree_getMerkleProof(leaf_index)` names it). The secret and the + proof's private inputs stay in Rust; the proof is generated locally. + +A node that is compromised or simply curious learns which buckets were +read and the ordinary accounts' balances, not which deposits are the +wallet's or which exits it will make. + ### The indexer `blackbeard.observer`'s `/v1` (`crates/wallet-data/src/history/observer.rs`)