From a2c89866a47f032cefddee9d5b135b8ddafff5b3 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Mon, 8 Jun 2026 12:04:32 +0200 Subject: [PATCH 1/3] fix: reject malformed privacy-preserving proof instead of panicking --- .../privacy_preserving_transaction/circuit.rs | 8 ++- lee/state_machine/src/validated_state_diff.rs | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit.rs index 50202a38..8a26d566 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit.rs @@ -31,7 +31,13 @@ impl Proof { } pub(crate) fn is_valid_for(&self, circuit_output: &PrivacyPreservingCircuitOutput) -> bool { - let inner: InnerReceipt = borsh::from_slice(&self.0).unwrap(); + // A malformed proof must be rejected as invalid, not panic: these bytes are + // attacker-controlled (`Proof` is a plain `Vec` at the borsh level, so + // `from_bytes` accepts any content), and a panic here would unwind the + // sequencer's block-production loop. + let Ok(inner) = borsh::from_slice::(&self.0) else { + return false; + }; let receipt = Receipt::new(inner, circuit_output.to_bytes()); receipt.verify(PRIVACY_PRESERVING_CIRCUIT_ID).is_ok() } diff --git a/lee/state_machine/src/validated_state_diff.rs b/lee/state_machine/src/validated_state_diff.rs index c26ac3e7..c354cf52 100644 --- a/lee/state_machine/src/validated_state_diff.rs +++ b/lee/state_machine/src/validated_state_diff.rs @@ -930,4 +930,61 @@ mod tests { "recipient should receive nothing" ); } + + /// Regression test: a `PrivacyPreservingTransaction` carrying a structurally invalid + /// proof must be rejected with a clean `Err`, never a panic. + /// + /// `Proof` is a plain `Vec` at the borsh level, so `from_bytes` accepts arbitrary + /// proof content. A validly-signed message with garbage proof bytes passes every + /// upstream check (commitments non-empty, no duplicates, nonces, signatures, validity + /// window) and reaches `Proof::is_valid_for`, which used to `unwrap()` the borsh + /// deserialization and panic — unwinding the sequencer's block-production loop. The fix + /// maps the deserialization failure to `false`, surfacing as + /// `InvalidPrivacyPreservingProof`. Before the fix this test panicked instead of failing. + #[test] + fn privacy_garbage_proof_is_rejected_not_panic() { + use lee_core::{ + Commitment, + account::Account, + program::{BlockValidityWindow, TimestampValidityWindow}, + }; + + use crate::{ + PrivacyPreservingTransaction, + privacy_preserving_transaction::{ + circuit::Proof, message::Message, witness_set::WitnessSet, + }, + }; + + let state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + // Minimal message that passes every check up to proof verification: a single + // commitment satisfies the non-empty requirement, no signers makes the + // nonce/signature checks vacuously true, and unbounded validity windows are valid + // for any block/timestamp. + let commitment = Commitment::new(&AccountId::new([1_u8; 32]), &Account::default()); + let message = Message { + public_account_ids: vec![], + nonces: vec![], + public_post_states: vec![], + encrypted_private_post_states: vec![], + new_commitments: vec![commitment], + new_nullifiers: vec![], + block_validity_window: BlockValidityWindow::new_unbounded(), + timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + }; + + // Garbage proof bytes: not a valid borsh-encoded `InnerReceipt`. + let garbage_proof = Proof::from_inner(vec![0xff_u8; 64]); + let witness_set = WitnessSet::for_message(&message, garbage_proof, &[]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); + + match result { + Err(LeeError::InvalidPrivacyPreservingProof) => {} + Err(other) => panic!("expected InvalidPrivacyPreservingProof, got {other:?}"), + Ok(_) => panic!("garbage proof was accepted instead of rejected"), + } + } } From b784ee565df579a8569241214b4bd4e7abce5e25 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Mon, 8 Jun 2026 19:36:28 +0200 Subject: [PATCH 2/3] fix: address review feedback --- .../privacy_preserving_transaction/circuit.rs | 4 ---- lee/state_machine/src/validated_state_diff.rs | 17 ++++++----------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit.rs index 8a26d566..cebef4cf 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit.rs @@ -31,10 +31,6 @@ impl Proof { } pub(crate) fn is_valid_for(&self, circuit_output: &PrivacyPreservingCircuitOutput) -> bool { - // A malformed proof must be rejected as invalid, not panic: these bytes are - // attacker-controlled (`Proof` is a plain `Vec` at the borsh level, so - // `from_bytes` accepts any content), and a panic here would unwind the - // sequencer's block-production loop. let Ok(inner) = borsh::from_slice::(&self.0) else { return false; }; diff --git a/lee/state_machine/src/validated_state_diff.rs b/lee/state_machine/src/validated_state_diff.rs index c354cf52..cfbd1703 100644 --- a/lee/state_machine/src/validated_state_diff.rs +++ b/lee/state_machine/src/validated_state_diff.rs @@ -932,17 +932,9 @@ mod tests { } /// Regression test: a `PrivacyPreservingTransaction` carrying a structurally invalid - /// proof must be rejected with a clean `Err`, never a panic. - /// - /// `Proof` is a plain `Vec` at the borsh level, so `from_bytes` accepts arbitrary - /// proof content. A validly-signed message with garbage proof bytes passes every - /// upstream check (commitments non-empty, no duplicates, nonces, signatures, validity - /// window) and reaches `Proof::is_valid_for`, which used to `unwrap()` the borsh - /// deserialization and panic — unwinding the sequencer's block-production loop. The fix - /// maps the deserialization failure to `false`, surfacing as - /// `InvalidPrivacyPreservingProof`. Before the fix this test panicked instead of failing. + /// proof must be rejected with a clean `Err`. #[test] - fn privacy_garbage_proof_is_rejected_not_panic() { + fn privacy_garbage_proof_is_rejected() { use lee_core::{ Commitment, account::Account, @@ -962,7 +954,10 @@ mod tests { // commitment satisfies the non-empty requirement, no signers makes the // nonce/signature checks vacuously true, and unbounded validity windows are valid // for any block/timestamp. - let commitment = Commitment::new(&AccountId::new([1_u8; 32]), &Account::default()); + let account_id = AccountId::from(&PublicKey::new_from_private_key( + &PrivateKey::try_new([1_u8; 32]).unwrap(), + )); + let commitment = Commitment::new(&account_id, &Account::default()); let message = Message { public_account_ids: vec![], nonces: vec![], From 5624f9e629e4f6aae4f97af73e9bba28c6bf6471 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Mon, 8 Jun 2026 19:36:28 +0200 Subject: [PATCH 3/3] chore: ignore RUSTSEC-2026-0173 (proc-macro-error2 unmaintained) --- .deny.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.deny.toml b/.deny.toml index 21d40007..3c08bc8d 100644 --- a/.deny.toml +++ b/.deny.toml @@ -16,6 +16,7 @@ ignore = [ { id = "RUSTSEC-2026-0118", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, { id = "RUSTSEC-2026-0119", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, { id = "RUSTSEC-2024-0370", reason = "transitive dependency of `logos-blockchain-http-api-common`, can't do anything than wait for upstream fix" }, + { id = "RUSTSEC-2026-0173", reason = "`proc-macro-error2` is unmaintained; pulled in transitively via `leptos_macro` and `overwatch-derive`, waiting on upstream fix" }, ] yanked = "deny" unused-ignored-advisory = "deny"