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