Skip to content

Commit fdb55f4

Browse files
authored
fix(consensus): ban peers sending invalid shielded proofs (#11054)
Failed Orchard/Ironwood Halo2 proofs, Orchard binding signatures, and Sprout JoinSplit signatures returned untyped string errors that collapsed to `InternalDowncastError` (mempool misbehaviour score 0) in `From<BoxError>`. A peer could therefore force expensive shielded verification indefinitely without ever being banned; the `RedPallas`/`Ed25519` entries already in the ban list were dead because the conversion never produced those variants. Type these verification failures as `Halo2VerificationFailed` / `SaplingVerificationFailed`, downcast `ed25519` and `reddsa` errors in `From<BoxError>`, and give them and the non-canonical proof-size errors (`OrchardProofSize`, `IronwoodProofSize`) a ban-worthy misbehaviour score. Ported from Zakura (#283, #285). Refs: GHSA-2p4c-3q4q-p463
1 parent fe8639e commit fdb55f4

5 files changed

Lines changed: 113 additions & 37 deletions

File tree

zebra-consensus/src/error.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ use crate::{block::MAX_BLOCK_SIGOPS, transaction::check::MAX_STANDARD_SCRIPTSIG_
2424
#[cfg(any(test, feature = "proptest-impl"))]
2525
use proptest_derive::Arbitrary;
2626

27+
#[cfg(test)]
28+
mod tests;
29+
2730
/// Workaround for format string identifier rules.
2831
const MAX_EXPIRY_HEIGHT: block::Height = block::Height::MAX_EXPIRY_HEIGHT;
2932

@@ -161,8 +164,13 @@ pub enum TransactionError {
161164
#[cfg_attr(any(test, feature = "proptest-impl"), proptest(skip))]
162165
RedPallas(zebra_chain::primitives::reddsa::Error),
163166

164-
// temporary error type until #1186 is fixed
165-
#[error("Downcast from BoxError to redjubjub::Error failed: {0}")]
167+
#[error("Sapling proof or signature verification failed")]
168+
SaplingVerificationFailed,
169+
170+
#[error("Orchard or Ironwood Halo2 proof verification failed")]
171+
Halo2VerificationFailed,
172+
173+
#[error("could not convert an asynchronous verification error: {0}")]
166174
InternalDowncastError(String),
167175

168176
#[error("either vpub_old or vpub_new must be zero")]
@@ -335,12 +343,26 @@ impl From<amount::Error> for TransactionError {
335343
// TODO: use a dedicated variant and From impl for each concrete type, and update callers (#5732)
336344
impl From<BoxError> for TransactionError {
337345
fn from(mut err: BoxError) -> Self {
338-
// TODO: handle redpallas::Error, ScriptInvalid, InvalidSignature
346+
// Preserve the concrete shielded proof/signature verification error types so they keep
347+
// their mempool misbehaviour score. Without these downcasts a failed Orchard/Ironwood
348+
// Halo2 proof, Orchard binding signature, or Sprout JoinSplit signature would collapse to
349+
// `InternalDowncastError` (score 0), letting a peer force verification without being banned.
350+
// See <https://github.com/ZcashFoundation/zebra/security/advisories/GHSA-2p4c-3q4q-p463>.
351+
match err.downcast::<zebra_chain::primitives::ed25519::Error>() {
352+
Ok(e) => return TransactionError::Ed25519(*e),
353+
Err(e) => err = e,
354+
}
355+
339356
match err.downcast::<zebra_chain::primitives::redjubjub::Error>() {
340357
Ok(e) => return TransactionError::RedJubjub(*e),
341358
Err(e) => err = e,
342359
}
343360

361+
match err.downcast::<zebra_chain::primitives::reddsa::Error>() {
362+
Ok(e) => return TransactionError::RedPallas(*e),
363+
Err(e) => err = e,
364+
}
365+
344366
match err.downcast::<ValidateContextError>() {
345367
Ok(e) => return (*e).into(),
346368
Err(e) => err = e,
@@ -394,6 +416,10 @@ impl TransactionError {
394416
| Ed25519(_)
395417
| RedJubjub(_)
396418
| RedPallas(_)
419+
| SaplingVerificationFailed
420+
| Halo2VerificationFailed
421+
| OrchardProofSize
422+
| IronwoodProofSize
397423
| BothVPubsNonZero
398424
| DisabledAddToSproutPool
399425
| NegativeOrchardValueBalance

zebra-consensus/src/error/tests.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//! Tests for [`TransactionError`] conversion and mempool misbehaviour scoring.
2+
3+
use super::*;
4+
5+
/// Boxed shielded proof and signature verification errors must keep their concrete type when
6+
/// converted back from a [`BoxError`], so the mempool can assign them a misbehaviour score instead
7+
/// of collapsing them to [`TransactionError::InternalDowncastError`] (score 0). See
8+
/// <https://github.com/ZcashFoundation/zebra/security/advisories/GHSA-2p4c-3q4q-p463>.
9+
#[test]
10+
fn boxed_signature_errors_are_preserved() {
11+
let ed25519_error: BoxError =
12+
Box::new(zebra_chain::primitives::ed25519::Error::InvalidSignature);
13+
assert_eq!(
14+
TransactionError::from(ed25519_error),
15+
TransactionError::Ed25519(zebra_chain::primitives::ed25519::Error::InvalidSignature)
16+
);
17+
18+
let redjubjub_error: BoxError =
19+
Box::new(zebra_chain::primitives::redjubjub::Error::InvalidSignature);
20+
assert_eq!(
21+
TransactionError::from(redjubjub_error),
22+
TransactionError::RedJubjub(zebra_chain::primitives::redjubjub::Error::InvalidSignature)
23+
);
24+
25+
let redpallas_error: BoxError =
26+
Box::new(zebra_chain::primitives::reddsa::Error::InvalidSignature);
27+
assert_eq!(
28+
TransactionError::from(redpallas_error),
29+
TransactionError::RedPallas(zebra_chain::primitives::reddsa::Error::InvalidSignature)
30+
);
31+
}
32+
33+
/// Every shielded proof/signature verification failure, and non-canonical Orchard/Ironwood proof
34+
/// sizes, must earn a ban-worthy mempool misbehaviour score, so a peer forcing expensive
35+
/// verification with invalid proofs is disconnected rather than allowed to keep sending. See
36+
/// <https://github.com/ZcashFoundation/zebra/security/advisories/GHSA-2p4c-3q4q-p463>.
37+
#[test]
38+
fn verification_errors_have_high_misbehavior_score() {
39+
for error in [
40+
TransactionError::SaplingVerificationFailed,
41+
TransactionError::Halo2VerificationFailed,
42+
TransactionError::OrchardProofSize,
43+
TransactionError::IronwoodProofSize,
44+
TransactionError::Ed25519(zebra_chain::primitives::ed25519::Error::InvalidSignature),
45+
TransactionError::RedJubjub(zebra_chain::primitives::redjubjub::Error::InvalidSignature),
46+
TransactionError::RedPallas(zebra_chain::primitives::reddsa::Error::InvalidSignature),
47+
] {
48+
assert_eq!(error.mempool_misbehavior_score(), 100, "{error:?}");
49+
}
50+
}

zebra-consensus/src/primitives/halo2.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use rand::thread_rng;
1919
use zcash_protocol::value::ZatBalance;
2020
use zebra_chain::{parameters::NetworkUpgrade, transaction::SigHash};
2121

22-
use crate::BoxError;
22+
use crate::{error::TransactionError, BoxError};
2323
use thiserror::Error;
2424
use tokio::sync::watch;
2525
use tower::Service;
@@ -441,7 +441,7 @@ impl Verifier {
441441
if spawn_fifo(move || item.verify_single(vk)).await? {
442442
Ok(())
443443
} else {
444-
Err("could not validate orchard proof".into())
444+
Err(TransactionError::Halo2VerificationFailed.into())
445445
}
446446
}
447447
}
@@ -501,7 +501,7 @@ impl Service<BatchControl<Item>> for Verifier {
501501
} else {
502502
tracing::trace!(?is_valid, "invalid halo2 proof");
503503
metrics::counter!("proofs.halo2.invalid").increment(1);
504-
Err("could not validate halo2 proofs".into())
504+
Err(TransactionError::Halo2VerificationFailed.into())
505505
}
506506
}
507507
Err(_recv_error) => panic!("verifier was dropped without flushing"),

zebra-consensus/src/primitives/sapling.rs

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::{
88
task::{Context, Poll},
99
};
1010

11-
use futures::{future::BoxFuture, FutureExt, TryFutureExt};
11+
use futures::{future::BoxFuture, FutureExt};
1212
use once_cell::sync::Lazy;
1313
use rand::thread_rng;
1414
use tokio::sync::watch;
@@ -21,6 +21,8 @@ use zcash_proofs::prover::LocalTxProver;
2121
use zcash_protocol::value::ZatBalance;
2222
use zebra_chain::transaction::SigHash;
2323

24+
use crate::{error::TransactionError, BoxError};
25+
2426
/// Sapling prover containing spend and output params for the Sapling circuit.
2527
///
2628
/// Used to:
@@ -108,28 +110,28 @@ impl Service<BatchControl<Item>> for Verifier {
108110
.batch
109111
.check_bundle(item.bundle, item.sighash.into())
110112
.then_some(())
111-
.ok_or("invalid Sapling bundle");
113+
.ok_or(TransactionError::SaplingVerificationFailed);
112114

113115
async move {
114-
bundle_check?;
116+
bundle_check.map_err(BoxError::from)?;
115117

116118
rx.changed()
117119
.await
118-
.map_err(|_| "verifier was dropped without flushing")
119-
.and_then(|_| {
120-
// We use a new channel for each batch, so we always get the correct
121-
// batch result here.
122-
rx.borrow()
123-
.ok_or("threadpool unexpectedly dropped channel sender")?
124-
.then(|| {
125-
metrics::counter!("proofs.sapling.verified").increment(1);
126-
})
127-
.ok_or_else(|| {
128-
metrics::counter!("proofs.sapling.invalid").increment(1);
129-
"batch verification of Sapling shielded data failed"
130-
})
131-
})
132-
.map_err(Self::Error::from)
120+
.map_err(|_| BoxError::from("verifier was dropped without flushing"))?;
121+
122+
// We use a new channel for each batch, so we always get the correct
123+
// batch result here.
124+
let is_valid = rx.borrow().ok_or_else(|| {
125+
BoxError::from("threadpool unexpectedly dropped channel sender")
126+
})?;
127+
128+
if is_valid {
129+
metrics::counter!("proofs.sapling.verified").increment(1);
130+
Ok(())
131+
} else {
132+
metrics::counter!("proofs.sapling.invalid").increment(1);
133+
Err(BoxError::from(TransactionError::SaplingVerificationFailed))
134+
}
133135
}
134136
.boxed()
135137
}
@@ -180,20 +182,23 @@ pub fn verify_single(
180182
.batch
181183
.check_bundle(item.bundle, item.sighash.into())
182184
.then_some(())
183-
.ok_or("invalid Sapling bundle");
184-
check?;
185+
.ok_or(TransactionError::SaplingVerificationFailed);
186+
check.map_err(BoxError::from)?;
185187

186-
tokio::task::spawn_blocking(move || {
188+
let is_valid = tokio::task::spawn_blocking(move || {
187189
let (spend_vk, output_vk) = SAPLING.verifying_keys();
188190

189191
mem::take(&mut verifier.batch).validate(&spend_vk, &output_vk, thread_rng())
190192
})
191193
.await
192-
.map_err(|_| "Sapling bundle validation thread panicked")?
193-
.then_some(())
194-
.ok_or("invalid proof or sig in Sapling bundle")
194+
.map_err(|_| BoxError::from("Sapling bundle validation thread panicked"))?;
195+
196+
if is_valid {
197+
Ok(())
198+
} else {
199+
Err(BoxError::from(TransactionError::SaplingVerificationFailed))
200+
}
195201
}
196-
.map_err(Box::from)
197202
.boxed()
198203
}
199204

zebra-consensus/src/transaction/tests.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2747,12 +2747,7 @@ fn v4_with_modified_joinsplit_is_rejected() {
27472747
zebra_test::MULTI_THREADED_RUNTIME.block_on(async {
27482748
v4_with_joinsplit_is_rejected_for_modification(
27492749
JoinSplitModification::CorruptSignature,
2750-
// TODO: Fix error downcast
2751-
// Err(TransactionError::Ed25519(ed25519::Error::InvalidSignature))
2752-
TransactionError::InternalDowncastError(
2753-
"downcast to known transaction error type failed, original error: InvalidSignature"
2754-
.to_string(),
2755-
),
2750+
TransactionError::Ed25519(ed25519::Error::InvalidSignature),
27562751
)
27572752
.await;
27582753

0 commit comments

Comments
 (0)