From e1eaf7703b7184bdfc7dc4c01ed98670f46dae1e Mon Sep 17 00:00:00 2001 From: Black Swan Date: Sun, 16 Aug 2026 00:22:20 +0300 Subject: [PATCH] fix: stop peer gossip from poisoning the CW seam parent height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consensus digest is `Poseidon(header.output)` and commits to nothing else — not `frame_number`. Channel-3 block ingress validates nothing before recording digest→frame_number, so any peer can pair a real frame's `output` with an arbitrary height and land it on that frame's digest. On the GLOBAL chain that is a live halt. `propose` reads the index as authoritative, so a poisoned entry is a HIT (not the miss the clock-store fallback covers) and `prove_next_state` fails on every view this node leads: cw propose: prove_next_state failed — cannot build a proposal view=3289 prior_frame_number=1000001 error=not found: needs sync: consensus parent is frame 1, told 1000001 Channel 3 is not even sender-attributed there (`CwInboundRouter::route` forwards it without resolving the peer), so one unauthenticated message is enough. Ingress now stores block bytes and nothing else, on both seams — the activation closures no longer hold the proposer at all, so the property is structural rather than a rule. The index keeps taking validated writes only (`propose`, `verify`, the activation seed), which is what the restart fallback and the genesis seed already rely on. Scope note: on the APP seam this is now defence-in-depth rather than a live fix. `resolve build errors, unified tree model, fix sync` (932045a3) deleted `app_header_extends_simplex_parent`, dropped `parent_digest` from `AppSeamProposer::verify`, and made `propose`'s height advisory (`unwrap_or(0)`, with `AppLeaderProvider::prove_next_state` ignoring it), so the forged height has no reachable consumer on that seam today. The ingress write remains the primitive, so it is removed there too. Dropped from the original version of this change accordingly: the identity-checked `resolve_parent_frame_number` fallback and its `clock_store` plumbing (they would reinstate the nullify-on-miss gate 932045a3 deliberately removed), the `verify` height gate (the code is gone), and the app-shard e2e attack test (it can no longer distinguish fixed from unfixed source; the two app-seam unit tests guard the primitive directly). `propose` also no longer swallows `prove_next_state` errors — that silence is why the halt left no on-disk signal. The global e2e rig is new: `activate_global_consensus_cw` had no harness. It drives a single-member committee and attacks through the real public `ingest_block`, keyed off the block broadcast rather than finalization (a finalize-keyed attacker is ~2 views late on a fast chain and proves nothing). Co-Authored-By: Claude Opus 5 (1M context) --- crates/quil-engine/src/cw_app_seams.rs | 183 ++++++++++-- crates/quil-engine/src/cw_global_seams.rs | 116 ++++++-- crates/quil-engine/tests/e2e_consensus.rs | 344 ++++++++++++++++++++++ 3 files changed, 601 insertions(+), 42 deletions(-) diff --git a/crates/quil-engine/src/cw_app_seams.rs b/crates/quil-engine/src/cw_app_seams.rs index d780c65b..12b6a3f7 100644 --- a/crates/quil-engine/src/cw_app_seams.rs +++ b/crates/quil-engine/src/cw_app_seams.rs @@ -126,6 +126,46 @@ fn decode_app_frame(bytes: &[u8]) -> Option { ::decode(bytes).ok() } +/// Feed a peer-delivered block into the shared [`BlockStore`] so `verify` finds +/// the bytes behind a proposed digest. Drops malformed bytes; idempotent. +/// +/// SECURITY — this path is UNVALIDATED. The node authorizes the sender only far +/// enough to resolve a peer key (`app_engine`'s `CwIn` handler), and nothing +/// here checks the frame: the VDF, the roots, and the parent linkage are all +/// `verify`'s job. Critically, the consensus digest is `Poseidon(header.output)` +/// and does NOT commit to `header.frame_number`, so a peer can pair any real +/// frame's `output` with any height it likes and have it land on the honest +/// frame's digest. +/// +/// So this function stores bytes and NOTHING else — it must never record +/// digest→frame_number. `propose` reads that index for the height it builds on; +/// today the app leader provider treats that height as advisory (it resolves its +/// parent from the shard clock store) and `verify` no longer derives anything +/// from it, so a forged entry currently has no reachable consumer here. The +/// global twin is not so lucky — the same primitive halts the global chain +/// outright (see `cw_global_seams::ingest_global_block`) — and the app seam has +/// already carried a height-derived `verify` gate once. Keeping the index +/// unreachable from unvalidated input makes the property structural instead of a +/// standing invariant for every future reader of `block_meta`. +/// +/// The block bytes themselves are safe to accept here: [`BlockStore`] seals the +/// exact bytes that pass `verify`, after which ingress cannot substitute a +/// different body under the same digest. +pub(crate) fn ingest_app_block(store: &BlockStore, bytes: Vec) { + let Some(frame) = decode_app_frame(&bytes) else { + tracing::debug!("cw app block ingress: undecodable frame, dropping"); + return; + }; + let Some(header) = frame.header.as_ref() else { return }; + let Some(digest) = app_frame_digest(&frame) else { return }; + let claimed_frame_number = header.frame_number; + store.put(digest, bytes); + tracing::debug!( + claimed_frame = claimed_frame_number, + "cw app block ingress: stored peer frame (height unverified)", + ); +} + // --------------------------------------------------------------------------- // Proposer (GlobalProposer seam) // --------------------------------------------------------------------------- @@ -159,7 +199,8 @@ pub struct AppSeamProposer { assemble: AppFrameAssembler, filter: Vec, /// digest → frame_number (resolves the parent frame number from the simplex - /// parent digest, which carries only the identity). + /// parent digest, which carries only the identity). VALIDATED WRITES ONLY — + /// see [`ingest_app_block`] and [`AppSeamProposer::note_frame`]. block_meta: Arc>>, /// Body-root cross-check (audit Finding #2); see [`AppRequestsRootCheck`]. requests_root_check: Option, @@ -183,11 +224,15 @@ impl AppSeamProposer { } } - /// Record digest → frame_number (used by inbound-block ingestion so a synced - /// parent resolves its number). + /// Record digest → frame_number for a block whose bytes this node has + /// VALIDATED (built in `propose`, accepted in `verify`) or that it read from + /// its own committed store (the activation seed). Never call this with a + /// height learned from a peer — that is the poisoning primitive documented + /// on [`ingest_app_block`]. pub fn note_frame(&self, digest: Digest, frame_number: u64) { self.block_meta.lock().unwrap().insert(digest, frame_number); } + } impl GlobalProposer for AppSeamProposer { @@ -203,10 +248,29 @@ impl GlobalProposer for AppSeamProposer { // App shards resolve their parent from the shard clock store internally, // so `prior_frame_number` is advisory; `prior_state_id` is the identity. - let state = self - .leader_provider - .prove_next_state(view, &self.filter, prior_frame_number, &prior_state_id) - .ok()?; + let state = match self.leader_provider.prove_next_state( + view, + &self.filter, + prior_frame_number, + &prior_state_id, + ) { + Ok(state) => state, + Err(e) => { + // Surface WHY (mirrors the global seam). Swallowing this is how a + // shard halt becomes invisible: the leader nullifies its own view + // with no on-disk signal, and across all leaders the shard spins + // through views producing nothing. + tracing::warn!( + view, + prior_frame_number, + parent = %hex::encode(&prior_state_id), + error = %e, + "cw app propose: prove_next_state failed — cannot build a proposal \ + (view nullifies)", + ); + return None; + } + }; // The engine assembles the FULL frame (header + recorded requests). let frame = (self.assemble)(&state)?; @@ -407,9 +471,10 @@ pub fn build_app_committee( pub struct AppConsensusCwHandle { pub inbound: [tokio::sync::mpsc::UnboundedSender>; 3], - /// Feed a peer-delivered app frame's bytes into the engine's `BlockStore` - /// (so `verify` finds the block behind a proposed digest) and record its - /// digest→frame_number. Idempotent; drops malformed bytes. + /// Feed a peer-delivered app frame's bytes into the engine's `BlockStore`, + /// so `verify` finds the block behind a proposed digest. Stores bytes only — + /// nothing a peer claims about a frame's height is recorded. Idempotent; + /// drops malformed bytes. See [`ingest_app_block`]. pub ingest_block: Arc) + Send + Sync>, /// Cooperative shutdown flag for the simplex host thread. Set it to stop this /// instance (the engine drops + the runtime thread returns) — used to REBUILD @@ -484,23 +549,12 @@ pub fn activate_app_consensus_cw( } }); - // Block ingress: decode a peer app frame, compute identity digest, store it, - // and note digest→frame_number for parent resolution. + // Block ingress: decode a peer app frame, compute its identity digest, and + // store the bytes so `verify` can find them. Deliberately has NO handle on + // the proposer's digest→frame-number index — see [`ingest_app_block`]. let ingest_block: Arc) + Send + Sync> = { let store = store.clone(); - let proposer = proposer.clone(); - Arc::new(move |bytes: Vec| { - let Some(frame) = decode_app_frame(&bytes) else { - tracing::debug!("cw app block ingress: undecodable frame, dropping"); - return; - }; - let Some(header) = frame.header.as_ref() else { return }; - let Some(digest) = app_frame_digest(&frame) else { return }; - let frame_number = header.frame_number; - store.put(digest, bytes); - proposer.note_frame(digest, frame_number); - tracing::debug!(frame = frame_number, "cw app block ingress: stored peer frame"); - }) + Arc::new(move |bytes: Vec| ingest_app_block(&store, bytes)) }; AppConsensusCwHandle { inbound, ingest_block, shutdown } @@ -548,4 +602,83 @@ mod tests { ) .is_none()); } + /// An app frame carrying `output` at `frame_number`. Only the fields the + /// digest and the parent checks read are meaningful. + fn test_frame(filter: &[u8], frame_number: u64, output: &[u8]) -> AppShardFrame { + AppShardFrame { + header: Some(quil_types::proto::global::FrameHeader { + address: filter.to_vec(), + frame_number, + output: output.to_vec(), + ..Default::default() + }), + requests: vec![], + storage_attestation: None, + } + } + + /// The attack behind the #593 review comments, at the unit level: the + /// consensus digest is `Poseidon(output)` and does not commit to + /// `frame_number`, so a peer can gossip a real frame's `output` under a + /// forged height and land on the honest frame's digest. Ingress must not be + /// able to move the height that `propose`/`verify` then trust. + #[test] + fn peer_ingress_cannot_forge_the_parent_height() { + let filter = vec![0x55u8; 32]; + let honest = test_frame(&filter, 7, b"honest-output"); + let digest = app_frame_digest(&honest).expect("frame digests"); + + // What `verify` records once the honest parent passes validation. + let meta: Mutex> = Mutex::new(HashMap::new()); + meta.lock().unwrap().insert(digest, 7); + + let store = BlockStore::new(); + ingest_app_block(&store, encode_app_frame(&honest)); + + // Attacker: identical `output` (→ identical digest), forged height. + let mut forged = honest.clone(); + forged.header.as_mut().unwrap().frame_number = u64::MAX / 2; + assert_eq!( + app_frame_digest(&forged), + Some(digest), + "the forgery must collide with the honest digest or it models nothing", + ); + ingest_app_block(&store, encode_app_frame(&forged)); + + assert_eq!( + meta.lock().unwrap().get(&digest).copied(), + Some(7), + "unvalidated peer ingress moved the parent height", + ); + + // Delivery still works, and the sealed bytes are immutable afterwards. + let honest_bytes = encode_app_frame(&honest); + store.seal(digest, honest_bytes.clone()); + ingest_app_block(&store, encode_app_frame(&forged)); + assert_eq!(store.get(&digest), Some(honest_bytes)); + } + + /// The hardening must not cost block delivery: `verify` reads the proposed + /// block out of the store, so ingress still has to put bytes there. + #[test] + fn peer_ingress_still_delivers_block_bytes() { + let filter = vec![0x55u8; 32]; + let frame = test_frame(&filter, 3, b"delivered-output"); + let digest = app_frame_digest(&frame).expect("frame digests"); + let bytes = encode_app_frame(&frame); + + let store = BlockStore::new(); + ingest_app_block(&store, bytes.clone()); + assert_eq!(store.get(&digest), Some(bytes.clone())); + + // Once the exact bytes pass validation they are sealed, and later + // ingress cannot substitute a different body under the same digest. + store.seal(digest, bytes.clone()); + let mut swapped = frame.clone(); + swapped.header.as_mut().unwrap().frame_number = 999; + ingest_app_block(&store, encode_app_frame(&swapped)); + assert_eq!(store.get(&digest), Some(bytes)); + + ingest_app_block(&store, b"not a frame".to_vec()); + } } diff --git a/crates/quil-engine/src/cw_global_seams.rs b/crates/quil-engine/src/cw_global_seams.rs index db307311..fd1874da 100644 --- a/crates/quil-engine/src/cw_global_seams.rs +++ b/crates/quil-engine/src/cw_global_seams.rs @@ -98,6 +98,41 @@ fn frame_digest(header: &GlobalFrameHeader) -> Option { Some(digest_from_identity(id)) } +/// Feed a peer-delivered frame into the shared [`BlockStore`] so `verify` finds +/// the bytes behind a proposed digest. Drops malformed bytes; idempotent. +/// +/// SECURITY — this path is UNVALIDATED, and on the global chain it is not even +/// sender-attributed (`CwInboundRouter::route` forwards channel 3 without +/// resolving the peer). The consensus digest is `Poseidon(header.output)` and +/// does NOT commit to `header.frame_number`, so any peer can pair a real frame's +/// `output` with an arbitrary height and land it on that frame's digest. +/// +/// So this function stores bytes and NOTHING else. It must never record +/// digest→frame_number: `propose` builds the next frame at the height that index +/// reports for the Simplex parent, and a forged entry makes `prove_next_state` +/// fail on every view this node leads — with no fallback, because a poisoned +/// entry is a HIT, not the miss the clock-store fallback below covers. The block +/// bytes themselves are safe to accept: [`BlockStore`] seals the exact bytes that +/// pass `verify`, after which ingress cannot substitute a different body. +/// +/// Mirrors `cw_app_seams::ingest_app_block`, where the same primitive halts a +/// shard outright (the app seam also derives the child's expected height and +/// parent linkage from this index). +pub(crate) fn ingest_global_block(store: &BlockStore, bytes: Vec) { + let Ok(frame) = decode_global_frame(&bytes) else { + tracing::debug!("cw block ingress: undecodable frame, dropping"); + return; + }; + let Some(header) = frame.header.as_ref() else { return }; + let Some(digest) = frame_digest(header) else { return }; + let claimed_frame_number = header.frame_number; + store.put(digest, bytes); + tracing::debug!( + claimed_frame = claimed_frame_number, + "cw block ingress: stored peer frame (height unverified)", + ); +} + // --------------------------------------------------------------------------- // GlobalProposer // --------------------------------------------------------------------------- @@ -493,8 +528,9 @@ pub trait GlobalConsensusTransport: Send + Sync + 'static { pub struct GlobalConsensusCwHandle { pub inbound: [tokio::sync::mpsc::UnboundedSender>; 3], /// Feed a peer-delivered frame's canonical bytes into the engine's - /// `BlockStore` (so `verify` finds the block behind a proposed digest) and - /// record its digest→frame_number mapping. Idempotent; drops malformed bytes. + /// `BlockStore`, so `verify` finds the block behind a proposed digest. Stores + /// bytes only — nothing a peer claims about a frame's height is recorded. + /// Idempotent; drops malformed bytes. See [`ingest_global_block`]. pub ingest_block: Arc) + Send + Sync>, } @@ -579,24 +615,70 @@ pub fn activate_global_consensus_cw( } }); - // Block ingress: decode a peer frame, compute its identity digest, insert - // into the store, and note digest→frame_number for parent resolution. + // Block ingress: decode a peer frame, compute its identity digest, and store + // the bytes. Deliberately has NO handle on the proposer's digest→frame-number + // index — see [`ingest_global_block`]. let ingest_block: Arc) + Send + Sync> = { let store = store.clone(); - let proposer = proposer.clone(); - Arc::new(move |bytes: Vec| { - let Ok(frame) = decode_global_frame(&bytes) else { - tracing::debug!("cw block ingress: undecodable frame, dropping"); - return; - }; - let Some(header) = frame.header.as_ref() else { return }; - let Some(digest) = frame_digest(header) else { return }; - let frame_number = header.frame_number; - store.put(digest, bytes); - proposer.note_frame(digest, frame_number); - tracing::debug!(frame = frame_number, "cw block ingress: stored peer frame"); - }) + Arc::new(move |bytes: Vec| ingest_global_block(&store, bytes)) }; GlobalConsensusCwHandle { inbound, ingest_block } } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_frame(frame_number: u64, output: &[u8]) -> GlobalFrame { + GlobalFrame { + header: Some(GlobalFrameHeader { + frame_number, + output: output.to_vec(), + ..Default::default() + }), + requests: vec![], + } + } + + /// The global twin of `cw_app_seams::peer_ingress_cannot_forge_the_parent_height`. + /// Channel 3 is not even sender-attributed here, and the digest is + /// `Poseidon(output)` — which does not commit to `frame_number` — so peer + /// ingress must not be able to move the height `propose` builds on. A forged + /// entry would be an index HIT, so the restart fallback would not cover it: + /// every view this node leads would nullify. + #[test] + fn peer_ingress_cannot_forge_the_parent_height() { + let honest = test_frame(41, b"honest-global-output"); + let digest = frame_digest(honest.header.as_ref().unwrap()).expect("frame digests"); + + // What `propose`/`verify` record once the bytes are validated. + let meta: Mutex> = Mutex::new(HashMap::new()); + meta.lock().unwrap().insert(digest, 41); + + let store = BlockStore::new(); + let honest_bytes = encode_global_frame(&honest).expect("frame encodes"); + ingest_global_block(&store, honest_bytes.clone()); + assert_eq!(store.get(&digest), Some(honest_bytes.clone())); + + // Attacker: identical `output` (→ identical digest), forged height. + let mut forged = honest.clone(); + forged.header.as_mut().unwrap().frame_number = u64::MAX / 2; + assert_eq!( + frame_digest(forged.header.as_ref().unwrap()), + Some(digest), + "the forgery must collide with the honest digest or it models nothing", + ); + ingest_global_block(&store, encode_global_frame(&forged).expect("frame encodes")); + + assert_eq!( + meta.lock().unwrap().get(&digest).copied(), + Some(41), + "unvalidated peer ingress moved the parent height", + ); + // Delivery still works, and the sealed bytes are immutable afterwards. + store.seal(digest, honest_bytes.clone()); + ingest_global_block(&store, encode_global_frame(&forged).expect("frame encodes")); + assert_eq!(store.get(&digest), Some(honest_bytes)); + } +} diff --git a/crates/quil-engine/tests/e2e_consensus.rs b/crates/quil-engine/tests/e2e_consensus.rs index 996ed49c..77e3ff30 100644 --- a/crates/quil-engine/tests/e2e_consensus.rs +++ b/crates/quil-engine/tests/e2e_consensus.rs @@ -148,6 +148,350 @@ async fn app_consensus_cw_multi_prover_finalizes() { ); } +// =================================================================== +// Global-consensus CW rig — the global twin of the app-seam ingress hardening. +// +// `activate_global_consensus_cw` is only wired inside `quil-node`, so there is +// no harness for it. It is fully stub-able though: a single-member committee +// self-finalizes (quorum 1) with a no-op transport, so the whole global seam can +// be driven in-process and attacked through its real public ingress. +// =================================================================== + +/// A CW transport that plays the attacker. +/// +/// A single-member committee is its own quorum and `Automaton::propose` seals +/// the bytes it just built, so nothing actually has to travel — but the leader +/// still ships every proposal's frame bytes on the block channel, and that +/// broadcast is precisely what a network peer sees. So this transport watches +/// channel 3 and immediately gossips the frame back with a forged height. +/// +/// Tapping the broadcast is what makes the attack faithful. The forgery lands +/// while the frame it targets is still the Simplex parent — the leader has not +/// yet built (VDF-proved, in production) the frame above it. An attacker keyed +/// off FINALIZATION instead would always be about two views late, poisoning a +/// parent consensus had already moved past, and would prove nothing. +struct ForgingGlobalTransport { + /// Set once `activate_global_consensus_cw` hands back the real ingress. + ingest: Mutex) + Send + Sync>>>, + forged: std::sync::atomic::AtomicUsize, +} + +impl ForgingGlobalTransport { + fn new() -> Self { + Self { + ingest: Mutex::new(None), + forged: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn arm(&self, ingest: Arc) + Send + Sync>) { + *self.ingest.lock() = Some(ingest); + } + + fn forged(&self) -> usize { + self.forged.load(std::sync::atomic::Ordering::Relaxed) + } +} + +impl quil_engine::cw_global_seams::GlobalConsensusTransport for ForgingGlobalTransport { + fn deliver( + &self, + channel: u64, + _recipients: Vec, + bytes: Vec, + ) { + if channel != quil_engine::cw_global_seams::CW_BLOCK_CHANNEL { + return; + } + // Clone the handle out before calling — never hold this lock across the + // ingress, which takes the BlockStore's. + let ingest = self.ingest.lock().clone(); + let Some(ingest) = ingest else { return }; + let Ok(mut frame) = quil_engine::consensus_wire::decode_global_frame(&bytes) else { + return; + }; + let Some(header) = frame.header.as_mut() else { return }; + // Same `output` → same consensus digest. The digest is + // `Poseidon(header.output)` and commits to nothing else, so this forgery + // lands on the very entry Simplex hands the seam as the parent. + header.frame_number += 1_000_000; + let Ok(forged) = quil_engine::consensus_wire::encode_global_frame(&frame) else { + return; + }; + ingest(forged); + self.forged + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } +} + +/// A `LeaderProvider` that models the one property this attack +/// turns on: the real provider is TOLD the parent's height and looks the parent +/// frame up at it, so a height that doesn't belong to the parent identity is an +/// error ("needs sync"), not a frame built somewhere else. Everything else is +/// the minimum needed to produce a chain the stub verifier accepts. +struct HeightCheckedGlobalLeaderProvider { + prover: Vec, + requests_root: Vec, + /// frame identity (`Poseidon(output)`) → the height that identity actually is. + heights: Mutex, u64>>, +} + +impl quil_consensus::leader_provider::LeaderProvider + for HeightCheckedGlobalLeaderProvider +{ + fn get_next_leaders( + &self, + _prior: Option<&quil_consensus::models::State>, + ) -> quil_types::error::Result> { + Ok(vec![self.prover.clone()]) + } + + fn prove_next_state( + &self, + rank: u64, + _filter: &[u8], + prior_frame_number: u64, + prior_state: &quil_consensus::models::Identity, + ) -> quil_types::error::Result< + quil_consensus::models::State, + > { + match self.heights.lock().get(prior_state.as_slice()).copied() { + Some(actual) if actual == prior_frame_number => {} + Some(actual) => { + return Err(quil_types::error::QuilError::NotFound(format!( + "needs sync: consensus parent is frame {actual}, told {prior_frame_number}" + ))) + } + None => { + return Err(quil_types::error::QuilError::NotFound(format!( + "frame {prior_frame_number} not found" + ))) + } + } + + // Output must be unique per (frame, rank, parent) — the frame identity + // IS the consensus digest, so colliding outputs collapse the chain. + let frame_number = prior_frame_number + 1; + let mut seed = Vec::with_capacity(48); + seed.extend_from_slice(&frame_number.to_be_bytes()); + seed.extend_from_slice(&rank.to_be_bytes()); + seed.extend_from_slice(prior_state); + let h = quil_crypto::poseidon::hash_bytes_to_32(&seed).expect("poseidon hashes"); + let mut output = vec![0u8; 516]; + output[..32].copy_from_slice(&h); + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock is after the epoch") + .as_millis() as i64; + let header = gpb::GlobalFrameHeader { + frame_number, + rank, + timestamp, + difficulty: 1, + output, + parent_selector: prior_state.to_vec(), + prover: self.prover.clone(), + requests_root: self.requests_root.clone(), + ..Default::default() + }; + let identity = quil_crypto::poseidon::hash_bytes_to_32(&header.output) + .expect("poseidon hashes") + .to_vec(); + self.heights.lock().insert(identity.clone(), frame_number); + + Ok(quil_consensus::models::State { + rank, + identifier: identity, + proposer_id: self.prover.clone(), + parent_qc_identity: prior_state.to_vec(), + parent_qc_rank: rank.saturating_sub(1), + parent_quorum_certificate: None, + timestamp: timestamp as u64, + state: quil_engine::consensus_types::GlobalState::from_header(&header), + }) + } +} + +/// The global twin of `app_consensus_cw_survives_forged_parent_height_gossip`, +/// driven through the real public ingress `GlobalConsensusCwHandle::ingest_block`. +/// +/// The global block channel is worse off than the app one: `CwInboundRouter` +/// forwards channel 3 with no sender check at all, so these bytes need not come +/// from a peer the node has ever authenticated. And as on the app side the +/// consensus digest is `Poseidon(header.output)`, which does not commit to +/// `header.frame_number` — so a forged height lands on the honest frame's digest. +/// +/// If ingress reached the seam's digest→frame-number index, every view this node +/// leads would ask the leader provider to build on a height the consensus parent +/// does not have. That is an index HIT, so the restart fallback (which only +/// covers a miss) never fires, and the proposal fails on every view: the chain +/// stops. Here one node IS the committee, so "every view this node leads" is +/// every view — the chain must keep finalizing anyway. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn global_consensus_cw_survives_forged_parent_height_gossip() { + use quil_types::crypto::Signer as _; + + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_test_writer() + .try_init(); + + // One member ⇒ quorum 1 ⇒ this node finalizes its own proposals. + let signer = quil_crypto::FalconSigner::generate(); + let public_key = signer.public_key().to_vec(); + let private_key = signer.private_key().to_vec(); + let committee = quil_cw_consensus::committee::build_global_committee( + &[public_key.clone()], + &private_key, + &public_key, + b"global", + ) + .expect("single-member global committee builds"); + let prover_address = quil_crypto::poseidon::hash_bytes_to_32(&public_key) + .expect("poseidon hashes") + .to_vec(); + + // Empty request body — the seam's `verify` and the finalizer both recompute + // this root from the carried requests and reject a mismatch. + let requests_root = quil_engine::leader_provider::compute_global_requests_root( + &[], + &quil_tries::ShaInclusionProver, + ); + + // Genesis: the frame the first proposal extends, and the floor Simplex is + // handed as its parent digest. + let genesis_output = { + let mut out = vec![0u8; 516]; + out[..32].copy_from_slice( + &quil_crypto::poseidon::hash_bytes_to_32(b"cw-global-attack-genesis") + .expect("poseidon hashes"), + ); + out + }; + let genesis_identity = quil_crypto::poseidon::hash_bytes_to_32(&genesis_output) + .expect("poseidon hashes"); + let genesis_digest = quil_cw_consensus::adapters::digest_from_identity(genesis_identity); + let genesis = gpb::GlobalFrame { + header: Some(gpb::GlobalFrameHeader { + frame_number: 0, + output: genesis_output, + requests_root: requests_root.clone(), + prover: prover_address.clone(), + ..Default::default() + }), + requests: vec![], + }; + + let clock_store = Arc::new(InMemoryClockStore::new()); + clock_store.seed_frame(genesis.clone()); + + let leader_provider = Arc::new(HeightCheckedGlobalLeaderProvider { + prover: prover_address.clone(), + requests_root, + heights: Mutex::new(std::collections::HashMap::from([( + genesis_identity.to_vec(), + 0u64, + )])), + }); + let verifier = Arc::new(quil_engine::frame_validator::GlobalFrameVerifier::new( + Arc::new(StubFrameProver), + )); + + let (mat_job_tx, mut mat_job_rx) = mpsc::unbounded_channel::<(gpb::GlobalFrame, u64)>(); + let storage_directory = std::env::temp_dir().join(format!( + "cw-global-attack-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock is after the epoch") + .as_nanos(), + )); + + let transport = Arc::new(ForgingGlobalTransport::new()); + let handle = quil_engine::cw_global_seams::activate_global_consensus_cw( + committee.scheme, + committee.peers, + leader_provider, + verifier, + clock_store.clone(), + mat_job_tx, + Arc::new(|_, _| {}), + vec![0u8; 32], + 0, // epoch + genesis_digest, + 0, // genesis frame number + 5, // leader_timeout_secs — the stub proposer is instant + transport.clone(), + storage_directory.clone(), + None, // no gossip publisher + prover_address, + ); + // Arm the attacker with the real public ingress. The engine is still opening + // its simplex journal at this point, so the first proposal is attacked too. + transport.arm(handle.ingest_block.clone()); + + let mut finalized: Vec = Vec::new(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(60); + while finalized.len() < 4 { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match tokio::time::timeout(remaining, mat_job_rx.recv()).await { + Ok(Some((frame, _))) => finalized.push(frame), + // Channel closed (engine gone) or the deadline expired. + Ok(None) | Err(_) => break, + } + } + let forged_count = transport.forged(); + let _ = std::fs::remove_dir_all(&storage_directory); + + assert_eq!( + finalized.len(), + 4, + "global CW consensus stopped finalizing under forged parent-height gossip \ + (finalized {} frame(s), {forged_count} forgeries delivered) — unvalidated \ + channel-3 ingress poisoned the digest→frame-number index `propose` builds on", + finalized.len(), + ); + assert!( + forged_count > 0, + "the attacker never gossiped a forgery — the chain went unattacked", + ); + + let headers = finalized + .iter() + .map(|frame| frame.header.as_ref().expect("finalized frame has a header")) + .collect::>(); + assert_eq!( + headers.iter().map(|h| h.frame_number).collect::>().len(), + headers.len(), + "finalized duplicate global frame numbers", + ); + for pair in headers.windows(2) { + let (parent, child) = (pair[0], pair[1]); + assert_eq!( + child.frame_number, + parent.frame_number + 1, + "finalized global chain contains a height gap under attack", + ); + let parent_identity = quil_crypto::poseidon::hash_bytes_to_32(&parent.output) + .expect("parent output hashes") + .to_vec(); + assert_eq!( + child.parent_selector, parent_identity, + "finalized global frame does not extend its predecessor under attack", + ); + assert!( + child.frame_number < 1_000_000, + "a forged height reached the finalized global chain", + ); + } +} /// Active PoRep path end-to-end through the live consensus harness. /// /// Each of the 4 workers gets a shared committed CRDT (vertices under the