fix(lite-backend): never hang or panic a sync on malformed server data

The lightwalletd server controls the compact ciphertext length, the
block, prev-block and tx hash lengths, and (through the chain) the full
txs' epks. Each of these could wedge or poison a sync:

- try_sapling_compact_note_decryption asserts a 52-byte ciphertext. A
  shorter (or full 580-byte) one panicked a pool job in
  scan_output_internal while the original sender stayed alive, so the
  collector waited forever with sync_lock, txs.write() and a wallet read
  guard held.
- scan_taddress_txids kept its own sender alive too: one panicking
  t-address job (scan_full_tx) left crx.iter().take(n) blocked forever.
- 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(): a 31-byte tx hash poisoned the wallet for good, and a bad
  hash in the block's txid list panicked after the witnesses had moved.
- scan_full_tx and scan_full_mempool_tx unwrapped as_prime_order() on
  the epk. Consensus rejects only a small-order epk, so a mixed-order
  one can be mined: an own tx then failed every sync, and the mempool
  receiver thread died silently.

Changes:
- append_leaf(node: Node, ..) commits a leaf to the existing, block and
  new witnesses and the tree; the epk-error path, the new
  short-ciphertext path and the normal path all use it (a
  behaviour-neutral extraction).
- A compact ciphertext under 52 bytes is skipped with its cmu still
  committed; a longer one is truncated to 52 bytes, which is exact (the
  compact plaintext is those bytes and the cmu re-check validates it).
- Each decryption job catches a panic and sends exactly one message;
  drop(tx) after the dispatch, and the collector logs a vanished or
  panicked job and treats the output as not ours.
- scan_taddress_txids runs through the private seam
  scan_taddress_txids_with(job): catch_unwind per job, drop(ctx), and a
  count check, so a dead job is an error, never a hang or a silent
  partial success.
- scan_block_with_pool checks the block, prev-block and vtx hash lengths
  right after the parse and returns Err(-1), as for an unparsable block,
  before any change (Err(height) would refetch the same block forever
  under sync_lock).
- scan_full_tx and scan_full_mempool_tx skip an output whose epk is not
  in the prime-order subgroup (one check per output, used by every
  decryption and the outgoing recovery).
- The mempool receiver thread catches a panic of scan_full_mempool_tx.

Flagged behaviour changes:
- a compact ciphertext under 52 bytes is skipped (its cmu is still
  committed); one over 52 bytes is truncated;
- pool-job panics are reported (logged; the output is treated as not
  ours, which a rescan repairs) instead of hanging the sync;
- bad hash lengths return Err(-1) before any change;
- outputs with a non-prime-order epk are skipped;
- the mempool thread survives a bad tx.
Wallet format v9 is unchanged; no ABI or symbol change. Honest data
takes the same path as before (an old-vs-new run over generated honest
chains is byte-identical).

Tests (each on its own thread under a 15 s watchdog):
0/51/52/53/580-byte ciphertexts in one block on the inline and deferred
witness paths (the 51-byte notes are skipped, the 53- and 580-byte notes
are found, the tree and every witness match the reference); a 31-byte tx
hash, an empty block hash, a 31-byte prev hash and a 33-byte foreign tx
hash each return Err(-1) with the txs lock usable and
last_scanned_height, trees and witnesses unchanged; a mixed-order epk in
scan_full_tx and scan_full_mempool_tx does not panic and decrypts
nothing while the real epk still decrypts; a t-address job panicking for
1 of 3 addresses is an Err. The oracle generator gains short
(0..=51-byte) and full (580-byte) ciphertext outputs without an extra
random draw, so each seed keeps its sequence of blocks, values and
keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent 55431cd606
commit a5db2e9a3d
5 changed files with 450 additions and 82 deletions
@@ -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<Node>, defer_existing: bool, deferred_nodes: &mut Vec<Node>,
existing_witnesses: &mut [&mut IncrementalWitness<Node>],
block_witnesses: &mut [&mut IncrementalWitness<Node>],
new_witnesses: &mut [&mut IncrementalWitness<Node>]) {
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() {