diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs index defad0f..6dcdd06 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightclient.rs @@ -1171,7 +1171,13 @@ pub fn start_mempool_monitor(lc: Arc) -> Result<(), String> { &rtx.data[..]) { let light_wallet_clone = lc.wallet.clone(); - light_wallet_clone.read().unwrap().scan_full_mempool_tx(&tx, rtx.height as i32, 0, true); + // One bad mempool tx must not end this thread: the mempool monitor would stop silently. + let scanned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + light_wallet_clone.read().unwrap().scan_full_mempool_tx(&tx, rtx.height as i32, 0, true); + })); + if scanned.is_err() { + error!("Scanning mempool tx {} panicked; skipped it", tx.txid()); + } } } }); @@ -1631,26 +1637,14 @@ pub fn start_mempool_monitor(lc: Arc) -> Result<(), String> { end_height: u64, no_cert: bool ) -> Result, String> { - let addresses = self.wallet.read() - .map_err(|e| format!("Failed to read wallet: {:?}", e))? - .get_all_taddresses().iter() - .cloned() - .collect::>(); - - let (ctx, crx) = channel(); - let num_addresses = addresses.len(); - - for address in addresses { - let address_clone = address.clone(); + // The per-address job: fetch the address's txids and full-scan each tx (scan_full_tx). + self.scan_taddress_txids_with(pool, { let wallet = self.wallet.clone(); - let pool = pool.clone(); let server_uri = self.get_server_uri(); - let ctx = ctx.clone(); - let block_times = block_times.clone(); - - pool.execute(move || { - - let r = fetch_transparent_txids( + move |address: String| -> Result<(), String> { + let wallet = wallet.clone(); + let block_times = block_times.clone(); + fetch_transparent_txids( &server_uri, address, start_height, @@ -1687,16 +1681,49 @@ pub fn start_mempool_monitor(lc: Arc) -> Result<(), String> { }, } } - ); - + ) + } + }) + } + + // The t-address collector; `job` is the per-address work (a private seam, so a test can make a job panic). Every + // job sends exactly one result, a panicking one included, and only the jobs hold senders: the collector can + // neither wait forever on a job that died (with sync_lock held) nor report a partial scan as success. + fn scan_taddress_txids_with(&self, pool: &ThreadPool, job: F) -> Result, String> + where F: Fn(String) -> Result<(), String> + Send + Sync + 'static + { + let addresses = self.wallet.read() + .map_err(|e| format!("Failed to read wallet: {:?}", e))? + .get_all_taddresses().iter() + .cloned() + .collect::>(); + + let job = Arc::new(job); + let (ctx, crx) = channel(); + let num_addresses = addresses.len(); + + for address in addresses { + let address_clone = address.clone(); + let ctx = ctx.clone(); + let job = job.clone(); + + pool.execute(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (*job)(address))) + .unwrap_or_else(|_| Err("t-address scan job panicked".to_string())); match ctx.send(r) { Ok(_) => info!("Successfully sent data for address: {}", address_clone), Err(e) => error!("Failed to send data for address: {}: {:?}", address_clone, e), } }); } - - crx.iter().take(num_addresses).collect() + drop(ctx); + + // Ends once every job has sent its result and dropped its sender. + let results: Vec> = crx.iter().collect(); + if results.len() != num_addresses { + return Err(format!("t-address scan: {} of {} jobs reported", results.len(), num_addresses)); + } + results.into_iter().collect() } @@ -2088,4 +2115,36 @@ pub fn test_bad_import() { assert_eq!(lc.do_seed_phrase().unwrap()["seed"], TEST_SEED.to_string()); } + // C245 Fix 2 (s2-D6a seam). A t-address job that panicked left the collector waiting forever, with sync_lock + // held, for a result that never came: the original sender stayed alive. Each job now reports exactly once (a + // panic is an Err) and the collector counts the reports. Runs under the 15 s watchdog. + #[test] + pub fn test_taddress_scan_job_panic_is_error() { + let (n, all_ok, one_err, one_panic) = crate::lightwallet::tests::within_watchdog("t-address scan", || { + let tmp = TempDir::new("lctest").unwrap(); + let lc = LightClient::new(&main_config(&tmp), 0).unwrap(); + lc.do_new_address("R").unwrap(); + lc.do_new_address("R").unwrap(); + let addrs = lc.wallet.read().unwrap().get_all_taddresses(); + let pool = threadpool::ThreadPool::new(2); + let all_ok = lc.scan_taddress_txids_with(&pool, |_a: String| -> Result<(), String> { Ok(()) }); + let a1 = addrs[1].clone(); + let one_err = lc.scan_taddress_txids_with(&pool, move |a: String| -> Result<(), String> { + if a == a1 { Err("injected error".to_string()) } else { Ok(()) } + }); + let a1 = addrs[1].clone(); + let one_panic = lc.scan_taddress_txids_with(&pool, move |a: String| -> Result<(), String> { + if a == a1 { + panic!("injected t-address job panic"); + } + Ok(()) + }); + (addrs.len(), all_ok, one_err, one_panic) + }); + assert_eq!(n, 3); + assert_eq!(all_ok, Ok(vec![(); 3])); + assert_eq!(one_err, Err("injected error".to_string())); + assert_eq!(one_panic, Err("t-address scan job panicked".to_string())); + } + } diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs index 060464f..e8a988e 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs @@ -1544,6 +1544,15 @@ pub fn scan_full_tx(&self, tx: &Transaction, height: i32, datetime: u64) { } // Scan shielded sapling outputs to see if anyone of them is us, and if it is, extract the memo for output in tx.shielded_outputs.iter() { + // The epk is chain data. Sapling consensus rejects only a small-order epk, so one outside the prime-order + // subgroup can be mined; it is never ours (the compact scan skips it too). Skip the output, never panic. + let epk_prime = match output.ephemeral_key.as_prime_order(&JUBJUB) { + Some(p) => p, + None => { + warn!("Output in tx {} has a non-prime-order epk; skipping it", tx.txid()); + continue; + } + }; let ivks: Vec<_> = self.zkeys.read().unwrap().iter() .map(|zk| zk.extfvk.fvk.vk.ivk() ).collect(); @@ -1551,7 +1560,6 @@ pub fn scan_full_tx(&self, tx: &Transaction, height: i32, datetime: u64) { let ct = output.enc_ciphertext; // Search all of our keys for ivk in ivks { - let epk_prime = output.ephemeral_key.as_prime_order(&JUBJUB).unwrap(); let (note, _to, memo) = match try_sapling_note_decryption(&ivk, &epk_prime, &cmu, &ct) { Some(ret) => ret, None => continue, @@ -1593,7 +1601,7 @@ pub fn scan_full_tx(&self, tx: &Transaction, height: i32, datetime: u64) { &ovk, &output.cv, &output.cmu, - &output.ephemeral_key.as_prime_order(&JUBJUB).unwrap(), + &epk_prime, &output.enc_ciphertext, &output.out_ciphertext) { Some((note, payment_address, memo)) => { @@ -1603,7 +1611,6 @@ pub fn scan_full_tx(&self, tx: &Transaction, height: i32, datetime: u64) { // Check if this output belongs to our wallet using IVK decryption. // This correctly detects change sent to diversified addresses, // not just the wallet's default z-address. - let epk_prime = output.ephemeral_key.as_prime_order(&JUBJUB).unwrap(); let is_to_self = ivks_for_change.iter().any(|ivk| { try_sapling_note_decryption(ivk, &epk_prime, &output.cmu, &output.enc_ciphertext).is_some() }); @@ -1678,6 +1685,14 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64 } for output in tx.shielded_outputs.iter() { + // As in scan_full_tx: an epk outside the prime-order subgroup is never ours. Skip the output, never panic. + let epk_prime = match output.ephemeral_key.as_prime_order(&JUBJUB) { + Some(p) => p, + None => { + warn!("Mempool tx {} has an output with a non-prime-order epk; skipping it", tx.txid()); + continue; + } + }; let ivks: Vec<_> = self.zkeys.read().unwrap().iter() .map(|zk| zk.extfvk.fvk.vk.ivk()) .collect(); @@ -1687,7 +1702,6 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64 // Search all of our keys for ivk in ivks { - let epk_prime = output.ephemeral_key.as_prime_order(&JUBJUB).unwrap(); let (note, _to, memo) = match try_sapling_note_decryption(&ivk, &epk_prime, &cmu, &ct) { Some(ret) => ret, None => continue, @@ -1997,6 +2011,29 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64 }); } + /// Commit one leaf (an output's cmu): to the existing notes' witnesses (or the deferred list), to the witnesses of + /// this block's and this tx's earlier outputs, and to the tree. Every output whose cmu parses is a leaf, whether + /// or not it can be trial-decrypted; a skipped leaf makes every later anchor diverge from consensus. + fn append_leaf(node: Node, tree: &mut CommitmentTree, defer_existing: bool, deferred_nodes: &mut Vec, + existing_witnesses: &mut [&mut IncrementalWitness], + block_witnesses: &mut [&mut IncrementalWitness], + new_witnesses: &mut [&mut IncrementalWitness]) { + if defer_existing { + deferred_nodes.push(node); + } else { + for witness in existing_witnesses.iter_mut() { + witness.append(node).unwrap(); + } + } + for witness in block_witnesses.iter_mut() { + witness.append(node).unwrap(); + } + for witness in new_witnesses.iter_mut() { + witness.append(node).unwrap(); + } + tree.append(node).unwrap(); + } + /// Scans a [`CompactOutput`] with a set of [`ExtendedFullViewingKey`]s. /// /// Returns a [`WalletShieldedOutput`] and corresponding [`IncrementalWitness`] if this @@ -2037,25 +2074,23 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64 Ok(epk) => epk, Err(_) => { error!("Failed to parse epk for output index {} — committing cmu to tree, skipping decryption", index); - let node = Node::new(cmu.into()); - if defer_existing { - deferred_nodes.push(node); - } else { - for witness in existing_witnesses { - witness.append(node).unwrap(); - } - } - for witness in block_witnesses { - witness.append(node).unwrap(); - } - for witness in new_witnesses { - witness.append(node).unwrap(); - } - tree.append(node).unwrap(); + LightWallet::append_leaf(Node::new(cmu.into()), tree, defer_existing, deferred_nodes, + existing_witnesses, block_witnesses, new_witnesses); return None; } }; + // The server controls the ciphertext length, and try_sapling_compact_note_decryption asserts 52 bytes + // (COMPACT_NOTE_SIZE). Longer: the compact plaintext is exactly the first 52 bytes (the cmu re-check validates + // it), so truncate. Shorter: undecryptable; commit the cmu like any other leaf and skip decryption. let ct = output.ciphertext; + if ct.len() < 52 { + error!("Output index {} has a {}-byte compact ciphertext (expected 52) — committing cmu to tree, skipping decryption", + index, ct.len()); + LightWallet::append_leaf(Node::new(cmu.into()), tree, defer_existing, deferred_nodes, + existing_witnesses, block_witnesses, new_witnesses); + return None; + } + let ct = if ct.len() > 52 { ct[..52].to_vec() } else { ct }; let (tx, rx) = channel(); ivks.iter().enumerate().for_each(|(account, ivk)| { @@ -2066,48 +2101,45 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64 let tx = tx.clone(); pool.execute(move || { - let m = try_sapling_compact_note_decryption(&ivk, &epk, &cmu, &ct); - let r = match m { - Some((note, to)) => { - tx.send(Some(Some((note, to, account)))) - }, - None => { - tx.send(Some(None)) - } + // Exactly one message per job: a panic is caught and sent as None, so the collector below never + // waits for a result that cannot come. + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + try_sapling_compact_note_decryption(&ivk, &epk, &cmu, &ct) + })); + let msg = match r { + Ok(Some((note, to))) => Some(Some((note, to, account))), + Ok(None) => Some(None), + Err(_) => None, }; - - match r { - Ok(_) => {}, - Err(e) => println!("Send error {:?}", e) + if let Err(e) = tx.send(msg) { + error!("Compact decryption result lost: {:?}", e); } }); }); + // Only the jobs hold senders now: a job that ends without a message makes recv() below fail, not block. + drop(tx); // Increment tree and witnesses - let node = Node::new(cmu.into()); - if defer_existing { - deferred_nodes.push(node); - } else { - for witness in existing_witnesses { - witness.append(node).unwrap(); - } - } - for witness in block_witnesses { - witness.append(node).unwrap(); - } - for witness in new_witnesses { - witness.append(node).unwrap(); - } - tree.append(node).unwrap(); + LightWallet::append_leaf(Node::new(cmu.into()), tree, defer_existing, deferred_nodes, + existing_witnesses, block_witnesses, new_witnesses); // Collect all the RXs and fine if there was a valid result somewhere let mut wsos = vec![]; for _i in 0..ivks.len() { - let n = rx.recv().unwrap(); + let n = match rx.recv() { + Ok(n) => n, + Err(_) => { + error!("A compact decryption job for output index {} ended without a result", index); + None + } + }; let epk = epk.clone(); let wso = match n { - None => panic!("Got a none!"), + None => { + error!("Compact decryption failed (panic) for output index {}; treating it as not ours", index); + None + }, Some(None) => None, Some(Some((note, to, account))) => { // A note is marked as "change" if the account that received it @@ -2287,6 +2319,15 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64 } }; + // CompactBlock::hash()/prev_hash() assert 32 bytes when no header is sent, and every vtx hash is copied into a + // [u8; 32] (one of them under txs.write()). Malformed server data is an error before any change, never a panic. + // Err(-1) as for an unparsable block: Err(height) would refetch the same block forever under sync_lock. + if (block.header().is_none() && (block.hash.len() != 32 || block.prevHash.len() != 32)) + || block.vtx.iter().any(|t| t.hash.len() != 32) { + error!("Malformed CompactBlock at height {} (bad hash length)", block.height); + return Err(-1); + } + // Scanned blocks MUST be height-sequential. let height = block.get_height() as i32; if height == self.last_scanned_height() { diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/oracle_tests.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/oracle_tests.rs index 9b2a3a6..3d572e7 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/oracle_tests.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/oracle_tests.rs @@ -6,7 +6,8 @@ //! A seeded generator builds random fake compact chains and feeds them to a `LightWallet` //! through its public entry points (`scan_block_with_pool`, `invalidate_block`, `read`/`write`, //! `send_to_address`, `scan_full_tx`). It covers receives to HD, diversified, imported-sk, -//! view-only and foreign keys, zero-value and dust notes, outputs with an unparseable epk, +//! view-only and foreign keys, zero-value and dust notes, outputs with an unparseable epk or a +//! malformed compact ciphertext (0..=51 bytes: skipped; the full 580 bytes: truncated to 52), //! compact spends with change, real `send_to_address` sends that are mined later (or never), //! reorgs of 1..=100 blocks (in one call or block by block, with spends re-mined on the new //! branch), and save/reload. @@ -358,6 +359,18 @@ enum SendKind { Real, } +/// How `make_output` encodes an output's compact form. Every kind has a parseable cmu, i.e. is a leaf. +#[derive(Clone, Copy, PartialEq)] +enum OutKind { + Good, + /// A 31-byte (unparseable) epk: never decrypted. + BadEpk, + /// A 0..=51-byte ciphertext (C245): never decrypted. + ShortCt, + /// The full 580-byte ciphertext (C245): the compact plaintext is its first 52 bytes, so the note is found. + FullCt, +} + #[derive(Default)] struct Stats { blocks: usize, @@ -374,6 +387,9 @@ struct Stats { requeued: usize, remined: usize, full_checks: usize, + /// Generated outputs with a short (0..=51-byte) / the full (580-byte) compact ciphertext. + short_ct: usize, + full_ct: usize, /// Reorgs while a note is pending, by `at`: above the sent tip, across it (anchor kept), /// popping the anchor block. pending_reorgs: [usize; 3], @@ -566,7 +582,9 @@ impl Sim { } } - fn make_output(&mut self, ovk: OutgoingViewingKey, to: &PaymentAddress, value: u64, bad_epk: bool) + /// BadEpk and ShortCt draw the same 31 random bytes (the epk, or what sets the ciphertext length), so the + /// generator's stream does not depend on the kind: every seed generates the same chain as before ShortCt existed. + fn make_output(&mut self, ovk: OutgoingViewingKey, to: &PaymentAddress, value: u64, kind: OutKind) -> (CompactOutput, Note) { let note = Note { g_d: to.diversifier.g_d::(&JUBJUB).unwrap(), @@ -577,19 +595,36 @@ impl Sim { let enc = SaplingNoteEncryption::new(ovk, note.clone(), to.clone(), Memo::default(), &mut self.rng); let mut cmu = vec![]; note.cm(&JUBJUB).into_repr().write_le(&mut cmu).unwrap(); + let mut junk = vec![]; + if kind == OutKind::BadEpk || kind == OutKind::ShortCt { + junk.resize(31, 0); + self.rng.fill_bytes(&mut junk); + } let mut epk = vec![]; - if bad_epk { + if kind == OutKind::BadEpk { // Unparseable epk: the wallet must still append the cmu to the tree. - epk.resize(31, 0); - self.rng.fill_bytes(&mut epk); + epk = junk.clone(); } else { enc.epk().write(&mut epk).unwrap(); } let ct = enc.encrypt_note_plaintext(); + let ct = match kind { + // Undecryptable: the wallet must skip it (no hang, no panic) and still append the cmu to the tree. + OutKind::ShortCt => { + self.stats.short_ct += 1; + ct[..junk[0] as usize % 52].to_vec() + } + // The wallet must truncate it to 52 bytes and find a note of ours. + OutKind::FullCt => { + self.stats.full_ct += 1; + ct.to_vec() + } + _ => ct[..52].to_vec(), + }; let mut co = CompactOutput::new(); co.set_cmu(cmu); co.set_epk(epk); - co.set_ciphertext(ct[..52].to_vec()); + co.set_ciphertext(ct); (co, note) } @@ -604,7 +639,9 @@ impl Sim { } else { fvk.default_address().unwrap().1 }; - let (co, note) = self.make_output(fvk.fvk.ovk, &to, value, false); + // Value 7 mod 16 (no extra draw): the full 580-byte ciphertext. + let kind = if value % 16 == 7 { OutKind::FullCt } else { OutKind::Good }; + let (co, note) = self.make_output(fvk.fvk.ovk, &to, value, kind); (co, Owned { account, note, to }) } @@ -695,7 +732,15 @@ impl Sim { let (ovk, to) = self.foreign[self.rng.gen_range(0, self.foreign.len())].clone(); let v = self.gen_value(); let bad = self.rng.gen_bool(0.04); - let (co, _) = self.make_output(ovk, &to, v, bad); + // A bad output has an unparseable epk (even value) or a short ciphertext (odd value); a good one with + // value 7 mod 16 carries the full ciphertext. No extra draw: the generator's stream is unchanged. + let kind = match (bad, v % 2, v % 16) { + (true, 0, _) => OutKind::BadEpk, + (true, _, _) => OutKind::ShortCt, + (false, _, 7) => OutKind::FullCt, + _ => OutKind::Good, + }; + let (co, _) = self.make_output(ovk, &to, v, kind); ctx.outputs.push(co); ours.push(None); } @@ -1581,11 +1626,13 @@ impl Sim { fn summary(&self) -> String { let s = &self.stats; - format!("seed {:#x}: {} blocks, {} outputs ({} ours, {} notes), {} compact spends, {} reorgs (max {}), \ + format!("seed {:#x}: {} blocks, {} outputs ({} ours, {} notes; {} short-ct, {} full-ct), {} compact spends, \ + {} reorgs (max {}), \ {} reloads, {} probes, {} sends ({} mined), {} insufficient, {} requeued/{} re-mined, \ {} full checks, max {} cleared lists, {} keys; time gen {:.1}s scan {:.1}s ref {:.1}s \ check {:.1}s send {:.1}s reorg {:.1}s reload {:.1}s periodic {:.1}s; {}", - self.seed, s.blocks, s.outputs, s.ours, self.notes.len(), s.spends, s.reorgs, s.max_depth, + self.seed, s.blocks, s.outputs, s.ours, self.notes.len(), s.short_ct, s.full_ct, s.spends, s.reorgs, + s.max_depth, s.reloads, s.probes, s.sends, s.mined_sends, s.insufficient, s.requeued, s.remined, s.full_checks, self.cleared_seen.get(), self.w.zkeys.read().unwrap().len(), s.t_gen, s.t_scan, s.t_ref, s.t_check, s.t_send, s.t_reorg, s.t_reload, s.t_full, diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs index 047be05..3d910d0 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/tests.rs @@ -3696,3 +3696,100 @@ fn test_seed_phrase_empty_when_encrypted_seed_is_zero() { wallet.seed = [0u8; 32]; assert_eq!(wallet.get_seed_phrase(), ""); } + +/// Runs `f` on its own thread and returns its result, failing when there is none within 15 s: a scan that hangs (the +/// C245 failure mode) fails its test instead of wedging the suite. A panic in `f` is re-raised here. +pub(crate) fn within_watchdog T + Send + 'static>(what: &str, f: F) -> T { + use std::sync::mpsc::{channel, RecvTimeoutError}; + let (tx, rx) = channel(); + let t = std::thread::spawn(move || { + let _ = tx.send(f()); + }); + match rx.recv_timeout(std::time::Duration::from_secs(15)) { + Ok(v) => { + let _ = t.join(); + v + } + Err(RecvTimeoutError::Timeout) => panic!("{}: no result within the 15 s watchdog (hang)", what), + Err(RecvTimeoutError::Disconnected) => match t.join() { + Err(p) => std::panic::resume_unwind(p), + Ok(()) => panic!("{}: the test thread ended without a result", what), + }, + } +} + +#[test] +fn test_scan_full_tx_mixed_order_epk_no_panic() { + // C245 Fix 4. A full tx's epk is chain data: Sapling consensus rejects only a small-order epk, so an epk plus a + // point of order 2 can be mined. scan_full_tx and scan_full_mempool_tx unwrapped as_prime_order() and panicked + // (an own tx failed every sync; the mempool thread died). The output is skipped now; with its real epk the same + // output still decrypts. + within_watchdog("mixed-order epk", || { + use pairing::bls12_381::Fr; + use std::panic::{catch_unwind, AssertUnwindSafe}; + use zcash_primitives::jubjub::{edwards, Unknown}; + use zcash_primitives::transaction::components::{OutputDescription, GROTH_PROOF_SIZE}; + + let mut rng = OsRng; + let wallet = LightWallet::new(None, &get_test_config(), 0, 0).unwrap(); + let extfvk = wallet.zkeys.read().unwrap()[0].extfvk.clone(); + let to = extfvk.default_address().unwrap().1; + let note = Note { + g_d: to.diversifier.g_d::(&JUBJUB).unwrap(), + pk_d: to.pk_d.clone(), + value: 50_000, + r: Fs::random(&mut rng), + }; + let memo = Memo::from_bytes(b"C245 mixed-order epk").unwrap(); + let enc = SaplingNoteEncryption::new(extfvk.fvk.ovk, note.clone(), to.clone(), memo.clone(), &mut rng); + let cmu = note.cm(&JUBJUB); + let cv: edwards::Point = + edwards::Point::::rand(&mut rng, &JUBJUB).mul_by_cofactor(&JUBJUB).into(); + let epk: edwards::Point = enc.epk().clone().into(); + + // (0, -1) has order 2: epk + (0, -1) is on the curve but outside the prime-order subgroup. + let mut minus_one = Fr::one(); + minus_one.negate(); + let t2 = edwards::Point::::get_for_y(minus_one, false, &JUBJUB).unwrap(); + assert!(t2 != edwards::Point::zero() && t2.double(&JUBJUB) == edwards::Point::zero()); + let mixed = epk.add(&t2, &JUBJUB); + assert!(epk.as_prime_order(&JUBJUB).is_some() && mixed.as_prime_order(&JUBJUB).is_none()); + + let mut txid = [0u8; 32]; + rng.fill_bytes(&mut txid); + let tx_with_epk = |e: &edwards::Point| { + let mut data = TransactionData::new(); + data.shielded_outputs.push(OutputDescription { + cv: cv.clone(), + cmu, + ephemeral_key: e.clone(), + enc_ciphertext: enc.encrypt_note_plaintext(), + out_ciphertext: enc.encrypt_outgoing_plaintext(&cv, &cmu), + zkproof: [0u8; GROTH_PROOF_SIZE], + }); + Transaction { txid: TxId(txid), data } + }; + let honest = tx_with_epk(&epk); + let bad = tx_with_epk(&mixed); + + // The wallet holds the note (compact scan of the honest tx), without its memo yet. + let mut cb = FakeCompactBlock::new(0, BlockHash([0; 32])); + cb.add_tx(&honest); + wallet.scan_block(&cb.as_bytes()).unwrap(); + let memo_of = |w: &LightWallet| w.txs.read().unwrap()[&TxId(txid)].notes[0].memo.clone(); + assert!(memo_of(&wallet).is_none()); + + let r = catch_unwind(AssertUnwindSafe(|| wallet.scan_full_tx(&bad, 0, 0))); + assert!(r.is_ok(), "scan_full_tx panicked on a mixed-order epk"); + assert!(memo_of(&wallet).is_none(), "an output with a mixed-order epk was decrypted"); + let r = catch_unwind(AssertUnwindSafe(|| wallet.scan_full_mempool_tx(&bad, 1, 0, true))); + assert!(r.is_ok(), "scan_full_mempool_tx panicked on a mixed-order epk"); + assert!(wallet.incoming_mempool_txs.read().unwrap().is_empty()); + + // Control: the same output with its real epk is ours, in both scans. + wallet.scan_full_tx(&honest, 0, 0); + assert!(memo_of(&wallet) == Some(memo.clone()), "the honest output did not decrypt"); + wallet.scan_full_mempool_tx(&honest, 1, 0, true); + assert!(wallet.incoming_mempool_txs.read().unwrap().contains_key(&TxId(txid))); + }); +} diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/witness_tests.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/witness_tests.rs index cb2c583..67e46c3 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/witness_tests.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/witness_tests.rs @@ -37,6 +37,7 @@ use zcash_primitives::{ }; use super::data::SaplingNoteData; +use super::tests::within_watchdog; use super::{LightWallet, MAX_REORG}; use crate::lightclient::LightClientConfig; @@ -76,6 +77,11 @@ fn foreign() -> ExtendedFullViewingKey { /// An output paying `value` to the default address of `fvk` (the body of tests.rs add_tx_paying_to). fn out_to(fvk: &ExtendedFullViewingKey, value: u64) -> CompactOutput { + out_to_ct(fvk, value, 52) +} + +/// out_to whose compact ciphertext is the first `ct_len` bytes of the full 580-byte note ciphertext (out_to: 52). +fn out_to_ct(fvk: &ExtendedFullViewingKey, value: u64, ct_len: usize) -> CompactOutput { let mut rng = OsRng; let to = fvk.default_address().unwrap().1; let note = Note { @@ -94,7 +100,7 @@ fn out_to(fvk: &ExtendedFullViewingKey, value: u64) -> CompactOutput { let mut cout = CompactOutput::new(); cout.set_cmu(cmu); cout.set_epk(epk); - cout.set_ciphertext(enc_ciphertext[..52].to_vec()); + cout.set_ciphertext(enc_ciphertext[..ct_len].to_vec()); cout } @@ -794,3 +800,121 @@ fn deferred_append_path_with_bad_outputs() { deferred_case(17); deferred_case(3); } + +// C245: malformed server data. Each test runs on its own thread under a 15 s watchdog (within_watchdog), so the old +// failure mode, a scan waiting forever for a pool job that died, fails the test instead of wedging the suite. + +/// A block at the chain's next height on its tip, carrying `txs`. Not applied to the model. +fn raw_block(c: &Chain, txs: Vec) -> CompactBlock { + let mut cb = CompactBlock::new(); + cb.set_height(c.next as u64); + cb.hash = random_hash().to_vec(); + cb.prevHash = c.prev.0.to_vec(); + for (i, mut t) in txs.into_iter().enumerate() { + t.set_index(i as u64); + cb.vtx.push(t); + } + cb +} + +/// K notes, then one block whose outputs carry compact ciphertexts of 0, 51, 52, 53 and 580 bytes. The server controls +/// that length, and try_sapling_compact_note_decryption asserts 52. Shorter: the output cannot be trial-decrypted and +/// is skipped, even when it pays us, but its cmu is a leaf like any other. Longer (53, or the full 580 bytes from a +/// server that sends full ciphertexts): the compact plaintext is the first 52 bytes, so the note is found. The model is +/// the reference: it appends every parseable cmu whatever the ciphertext, i.e. exactly what a wallet fed 52-byte +/// ciphertexts in those slots commits. K = 3 takes the inline witness path, K = 17 the deferred one. +fn bad_ciphertext_case(k: usize) { + let mut c = Chain::new(); + for _ in 0..k { + let g = c.pay_us(10_000); + c.mine(vec![g]); + } + let ctx = format!("bad ciphertext K={}", k); + let f = foreign(); + let ours = c.ours(); + // tx1: our note; our note with a 51-byte ciphertext (skipped); a foreign note + let (t1, id1) = tx_with(vec![out_to(&ours, 1_111), out_to_ct(&ours, 9_999, 51), out_to(&f, 1)], vec![]); + // tx2: a foreign 51-byte ciphertext; our note with the full 580-byte ciphertext (found) + let (t2, id2) = tx_with(vec![out_to_ct(&f, 2, 51), out_to_ct(&ours, 2_222, 580)], vec![]); + // tx3: our note with a 53-byte ciphertext (found); a foreign empty ciphertext + let (t3, id3) = tx_with(vec![out_to_ct(&ours, 3_333, 53), out_to_ct(&f, 3, 0)], vec![]); + let size_before = c.model.tree.size(); + c.mine(vec![ + GenTx { ctx: t1, txid: id1, ours: Some(0), spends: vec![] }, + GenTx { ctx: t2, txid: id2, ours: Some(1), spends: vec![] }, + GenTx { ctx: t3, txid: id3, ours: Some(0), spends: vec![] }, + ]); + assert_eq!(c.model.tree.size() - size_before, 7, "{}: every output is a leaf", ctx); + c.compare(true, &ctx); + { + let txs = c.wallet.txs.read().unwrap(); + let values = |id: &TxId| txs[id].notes.iter().map(|nd| nd.note.value).collect::>(); + assert_eq!(values(&id1), vec![1_111], "{}: the 51-byte note must be skipped", ctx); + assert_eq!(values(&id2), vec![2_222], "{}: the 580-byte note must be found", ctx); + assert_eq!(values(&id3), vec![3_333], "{}: the 53-byte note must be found", ctx); + } + let root = c.wallet.blocks.read().unwrap().last().unwrap().tree.root(); + assert!(root == c.model.tree.root(), "{}: tip root differs from the reference", ctx); + assert!(check_anchor_alignment(&c.wallet, &ctx) >= k + 3, "{}: every live note is checked", ctx); + + // One more block: every note, including the three from the bad-ciphertext block, advances. + let g = c.pay_foreign(2); + c.mine(vec![g]); + c.compare(true, &ctx); + check_anchor_alignment(&c.wallet, &ctx); +} + +#[test] +fn bad_compact_ciphertext_is_skipped_or_truncated() { + within_watchdog("bad compact ciphertext", || { + bad_ciphertext_case(3); + bad_ciphertext_case(17); + }); +} + +/// Hash lengths are server data too. CompactBlock::hash()/prev_hash() assert 32 bytes when no header is sent, and every +/// tx hash was copied into a [u8; 32]: for a tx of ours under txs.write() (the panic poisoned the wallet for good), and +/// for the block's txid list after the witnesses had moved. Each is Err(-1) now, before any change: the lock is usable, +/// last_scanned_height and every tree and witness are unchanged, and the next good block scans. +#[test] +fn malformed_hashes_are_an_error_before_any_change() { + within_watchdog("malformed hashes", || { + let mut c = Chain::new(); + for _ in 0..3 { + let g = c.pay_us(10_000); + c.mine(vec![g]); + } + let ours = c.ours(); + let mut cases: Vec<(&str, CompactBlock)> = vec![]; + let (mut t, _) = tx_with(vec![out_to(&ours, 5_000)], vec![]); + t.hash.truncate(31); + cases.push(("our tx with a 31-byte hash", raw_block(&c, vec![t]))); + let (t, _) = tx_with(vec![out_to(&ours, 5_000)], vec![]); + let mut b = raw_block(&c, vec![t]); + b.hash = vec![]; + cases.push(("an empty block hash and no header", b)); + let mut b = raw_block(&c, vec![]); + b.prevHash.truncate(31); + cases.push(("a 31-byte prev hash and no header", b)); + let (t1, _) = tx_with(vec![out_to(&ours, 5_000)], vec![]); + let (mut t2, _) = tx_with(vec![out_to(&foreign(), 1)], vec![]); + t2.hash.push(0); + cases.push(("our tx and a foreign tx with a 33-byte hash", raw_block(&c, vec![t1, t2]))); + + let h = c.wallet.last_scanned_height(); + for (name, b) in cases { + let bytes = b.write_to_bytes().unwrap(); + let w = &c.wallet; + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| w.scan_block(&bytes))); + assert!(r.is_ok(), "{}: scan_block panicked", name); + assert_eq!(r.unwrap(), Err(-1), "{}", name); + assert!(c.wallet.txs.read().is_ok(), "{}: the txs lock is poisoned", name); + assert_eq!(c.wallet.last_scanned_height(), h, "{}: last_scanned_height moved", name); + c.compare(true, name); + } + let g = c.pay_us(7_000); + c.mine(vec![g]); + c.compare(true, "after the malformed blocks"); + check_anchor_alignment(&c.wallet, "after the malformed blocks"); + }); +}