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:
+89
-48
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user