diff --git a/Cargo.lock b/Cargo.lock index c613c605e..e6cd05b3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2330,7 +2330,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -2516,7 +2516,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2884,7 +2884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3497,9 +3497,9 @@ checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -3967,7 +3967,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -7206,7 +7206,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8255,7 +8255,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -8292,7 +8292,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", "windows-sys 0.59.0", ] @@ -9250,7 +9250,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10487,7 +10487,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11934,7 +11934,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index ec1da83fa..b6cfca6a7 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin index dc2c06472..aa7956d83 100644 Binary files a/artifacts/lez/programs/ping_receiver.bin and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index 6633ec468..8a62bf55a 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index b9c8d41ae..deeef623c 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -57,7 +57,7 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { src_program_id: programs::bridge_lock().id(), target_program_id: wrapped_token_id, }], - expected_block_signing_pubkey: None, + expected_block_signing_pubkeys: Vec::new(), }], }; diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index fb00410d2..9574ceb2a 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -51,7 +51,7 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { src_program_id: programs::ping_sender().id(), target_program_id: receiver_id, }], - expected_block_signing_pubkey: None, + expected_block_signing_pubkeys: Vec::new(), }], }; diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index 21cb4e102..84cf46460 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -50,7 +50,7 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { src_program_id: programs::ping_sender().id(), target_program_id: receiver_id, }], - expected_block_signing_pubkey: None, + expected_block_signing_pubkeys: Vec::new(), }], }; diff --git a/integration_tests/tests/cross_zone_watcher_restart.rs b/integration_tests/tests/cross_zone_watcher_restart.rs index e30875201..2aa78239f 100644 --- a/integration_tests/tests/cross_zone_watcher_restart.rs +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -62,7 +62,7 @@ async fn restarted_watcher_resumes_instead_of_replaying_the_peer_channel() -> Re src_program_id: programs::ping_sender().id(), target_program_id: receiver_id, }], - expected_block_signing_pubkey: None, + expected_block_signing_pubkeys: Vec::new(), }], }; diff --git a/lez/cross_zone/src/acceptance.rs b/lez/cross_zone/src/acceptance.rs index 85c6648c1..c4602c11c 100644 --- a/lez/cross_zone/src/acceptance.rs +++ b/lez/cross_zone/src/acceptance.rs @@ -11,7 +11,7 @@ use common::{ HashType, block::{Block, PeerChainTip}, }; -use cross_zone_inbox_core::ZoneId; +use cross_zone_inbox_core::{CrossZonePeer, ZoneId}; use lee::{GENESIS_BLOCK_ID, PublicKey}; /// Consecutive passes a reader spends stuck on one slot before it says so as @@ -84,9 +84,9 @@ impl Display for OffChain { /// The channel authorizes who may write, not what they may claim. The hash /// check is unconditional: the signature does not cover `header.hash`, and the /// chain link compares hashes, so an unchecked one lets a peer assert links it -/// never built. The pinned key, checked only when one is configured, is what -/// says the peer's own sequencer produced the block; it subsumes nothing here, -/// since a correctly signed block may still carry a bogus hash. +/// never built. The pinned keys, checked only when any are configured, are what +/// say one of the peer's own sequencers produced the block; they subsume +/// nothing here, since a correctly signed block may still carry a bogus hash. #[derive(Debug, PartialEq, Eq)] pub enum ScreenRefusal { /// `header.hash` is not the hash of the block's contents. @@ -95,7 +95,7 @@ pub enum ScreenRefusal { declared: HashType, recomputed: HashType, }, - /// The block is not signed by the pinned block-signing key. + /// The block is not signed by any pinned block-signing key. KeyMismatch { block_id: u64 }, } @@ -112,7 +112,7 @@ impl Display for ScreenRefusal { ), Self::KeyMismatch { block_id } => write!( f, - "block {block_id} is not signed by the pinned block-signing key" + "block {block_id} is not signed by any pinned block-signing key" ), } } @@ -244,7 +244,7 @@ pub fn link_to_tip(tip: Option<&PeerChainTip>, block: &Block, recomputed: HashTy /// all, returning the recomputed hash every later placement has to use. pub fn screen_peer_block( block: &Block, - expected_pubkey: Option<&PublicKey>, + expected_pubkeys: &[PublicKey], ) -> Result { let recomputed = block.recompute_hash(); if recomputed != block.header.hash { @@ -254,7 +254,7 @@ pub fn screen_peer_block( recomputed, }); } - if expected_pubkey.is_some_and(|key| !block.is_signed_by(key)) { + if !signed_by_any(block, expected_pubkeys) { return Err(ScreenRefusal::KeyMismatch { block_id: block.header.block_id, }); @@ -262,6 +262,29 @@ pub fn screen_peer_block( Ok(recomputed) } +/// Whether `block` is signed by one of the peer's pinned keys. +/// +/// Vacuously true with none pinned: the check is opt-in, and every place that +/// judges a peer block's signature goes through here so they cannot diverge on +/// that rule. +#[must_use] +pub fn signed_by_any(block: &Block, pinned: &[PublicKey]) -> bool { + pinned.is_empty() || pinned.iter().any(|key| block.is_signed_by(key)) +} + +/// The peer's configured block-signing keys, parsed. Panics on a malformed +/// entry: both sides read this at startup, so a bad config byte fails the +/// process before it judges any block. +#[must_use] +pub fn pinned_keys(peer: &CrossZonePeer) -> Vec { + peer.expected_block_signing_pubkeys + .iter() + .map(|&bytes| { + PublicKey::try_new(bytes).expect("configured peer block-signing pubkey is a valid key") + }) + .collect() +} + #[cfg(test)] mod tests { use common::test_utils::produce_dummy_block; @@ -290,7 +313,7 @@ mod tests { } fn screened(block: &Block) -> HashType { - screen_peer_block(block, None).expect("honest block passes screening") + screen_peer_block(block, &[]).expect("honest block passes screening") } /// Runs the stall machine over `(stuck_on, read_to)` pass results. @@ -389,14 +412,14 @@ mod tests { let mut tampered = chain_block(3); tampered.header.hash = HashType([9; 32]); assert!(matches!( - screen_peer_block(&tampered, None), + screen_peer_block(&tampered, &[]), Err(ScreenRefusal::HashMismatch { block_id: 3, .. }) )); // And the hash verdict comes first, whatever else is wrong. let other = lee::PublicKey::try_new([42; 32]).expect("test key"); assert!(matches!( - screen_peer_block(&tampered, Some(&other)), + screen_peer_block(&tampered, &[other]), Err(ScreenRefusal::HashMismatch { .. }) )); } @@ -408,14 +431,42 @@ mod tests { ); let block = chain_block(GENESIS_BLOCK_ID); assert_eq!( - screen_peer_block(&block, Some(&signer)), + screen_peer_block(&block, &[signer]), Ok(chain_hash(GENESIS_BLOCK_ID)), "produce_dummy_block signs with this key, so the pin must accept it" ); let other = lee::PublicKey::try_new([42; 32]).expect("test key"); assert!(matches!( - screen_peer_block(&block, Some(&other)), + screen_peer_block(&block, &[other]), + Err(ScreenRefusal::KeyMismatch { + block_id: GENESIS_BLOCK_ID + }) + )); + } + + #[test] + fn a_block_signed_by_any_key_in_the_set_is_accepted() { + // The multi-sequencer peer shape: the block's signer is one configured + // key among several, not the first one listed. + let signer = lee::PublicKey::new_from_private_key( + &lee::PrivateKey::try_new([37; 32]).expect("test key"), + ); + let other = lee::PublicKey::try_new([42; 32]).expect("test key"); + let block = chain_block(GENESIS_BLOCK_ID); + assert_eq!( + screen_peer_block(&block, &[other.clone(), signer]), + Ok(chain_hash(GENESIS_BLOCK_ID)), + "any listed key admits the block, whatever its position" + ); + + // A set none of whose keys signed refuses, exactly like a wrong single + // key. + let third = lee::PublicKey::new_from_private_key( + &lee::PrivateKey::try_new([99; 32]).expect("test key"), + ); + assert!(matches!( + screen_peer_block(&block, &[other, third]), Err(ScreenRefusal::KeyMismatch { block_id: GENESIS_BLOCK_ID }) diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 7593b78e8..b5551c485 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -11,7 +11,7 @@ pub use acceptance::{ Link, OffChain, STUCK_SLOT_ALERT_PASSES, ScreenRefusal, StallState, alerts_at, - equivocation_report, link_to_tip, screen_peer_block, + equivocation_report, link_to_tip, pinned_keys, screen_peer_block, signed_by_any, }; pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; use cross_zone_inbox_core::{ diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index 03cfd5b9e..f22f45713 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -17,7 +17,8 @@ use common::{ }; use cross_zone::{ EmissionSource, Link, OffChain, StallState, alerts_at, build_dispatch_from_emission, - equivocation_report, extract_emission, link_to_tip, screen_peer_block, + equivocation_report, extract_emission, link_to_tip, pinned_keys, screen_peer_block, + signed_by_any, }; use cross_zone_inbox_core::{ CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, message_key, @@ -623,12 +624,13 @@ struct TipEvidence { #[derive(Clone)] pub struct CrossZoneVerifier { self_zone: ZoneId, - /// Pinned block-signing key per peer zone, enforced during re-derivation. - /// One key per peer is sufficient while a zone has a single sequencer; key - /// sets with rotation come in with decentralized sequencing. The pin is - /// largely redundant given Bedrock's turn-based write authorization, so it is - /// optional: a peer with no configured key is not signature-checked. - peer_pubkeys: HashMap, + /// Pinned block-signing keys per peer zone, enforced during re-derivation: + /// a block is acceptable when signed by any of them, one entry per peer + /// sequencer. Rotation rules come later with decentralized sequencing. The + /// pin is largely redundant given Bedrock's turn-based write authorization, + /// so it is optional: a peer with no configured keys is not + /// signature-checked. + peer_pubkeys: HashMap>, peers: PeerBlocks, /// One channel client per peer, used only for the one-shot refetch of an /// evicted body. Built exactly like the reader's own client. @@ -666,17 +668,14 @@ impl CrossZoneVerifier { node.clone(), )), ); - if let Some(bytes) = peer.expected_block_signing_pubkey { - let pubkey = PublicKey::try_new(bytes) - .expect("configured peer block-signing pubkey is a valid key"); - peer_pubkeys.insert(peer.channel_id, pubkey); - } + let expected_pubkeys = pinned_keys(peer); + peer_pubkeys.insert(peer.channel_id, expected_pubkeys.clone()); watch.register(peer.channel_id); tokio::spawn(read_peer( ZoneIndexer::new(ChannelId::from(peer.channel_id), node), tip_node, peer.channel_id, - peer_pubkeys.get(&peer.channel_id).cloned(), + expected_pubkeys, peers.clone(), watch.clone(), config.consensus_info_polling_interval, @@ -832,14 +831,12 @@ impl CrossZoneVerifier { }, })?; - // Equivocation defense: the source block must be signed by the peer's - // pinned block-signing key, not merely inscribed on the channel. - if let Some(expected) = self.peer_pubkeys.get(&msg.src_zone) - && !peer_block.is_signed_by(expected) - { + // Equivocation defense: the source block must be signed by one of the + // peer's pinned block-signing keys, not merely inscribed on the channel. + if !signed_by_any(&peer_block, self.pinned_for(msg.src_zone)) { return Err(forged( msg, - "peer block is not signed by the pinned block-signing key".to_owned(), + "peer block is not signed by any pinned block-signing key".to_owned(), )); } @@ -1013,14 +1010,12 @@ impl CrossZoneVerifier { .next_messages(cursor) .await .map_err(|err| anyhow!("channel read failed: {err}"))?; - refetched_block( - stream, - read_at, - block_id, - block_hash, - self.peer_pubkeys.get(&zone), - ) - .await + refetched_block(stream, read_at, block_id, block_hash, self.pinned_for(zone)).await + } + + /// The keys pinned for `zone`, empty when none are configured. + fn pinned_for(&self, zone: ZoneId) -> &[PublicKey] { + self.peer_pubkeys.get(&zone).map_or(&[], Vec::as_slice) } } @@ -1095,12 +1090,8 @@ fn seen_key(msg: &CrossZoneMessage) -> SeenKey { /// says why each check exists. /// /// [`ScreenRefusal`]: cross_zone::ScreenRefusal -fn accept_peer_block( - block: &Block, - peer_zone: ZoneId, - expected_pubkey: Option<&PublicKey>, -) -> bool { - match screen_peer_block(block, expected_pubkey) { +fn accept_peer_block(block: &Block, peer_zone: ZoneId, expected_pubkeys: &[PublicKey]) -> bool { + match screen_peer_block(block, expected_pubkeys) { Ok(_) => true, Err(refusal) => { warn!( @@ -1122,7 +1113,7 @@ async fn read_peer( zone_indexer: ZoneIndexer, tip_node: NodeHttpClient, peer_zone: ZoneId, - expected_pubkey: Option, + expected_pubkeys: Vec, peers: PeerBlocks, watch: PeerWatch, poll_interval: Duration, @@ -1139,14 +1130,8 @@ async fn read_peer( loop { match zone_indexer.next_messages(cursor).await { Ok(stream) => { - let pass = consume_peer_stream( - stream, - peer_zone, - expected_pubkey.as_ref(), - &peers, - cursor, - ) - .await; + let pass = + consume_peer_stream(stream, peer_zone, &expected_pubkeys, &peers, cursor).await; cursor = pass.cursor; if let Some((slot, attempts)) = stall.after_pass(pass.stalled_at, pass.cursor) && alerts_at(attempts) @@ -1212,7 +1197,7 @@ async fn read_peer( async fn consume_peer_stream( stream: S, peer_zone: ZoneId, - expected_pubkey: Option<&PublicKey>, + expected_pubkeys: &[PublicKey], peers: &PeerBlocks, resume_from: Option, ) -> PeerPass @@ -1237,7 +1222,7 @@ where Ok(block) => { // Before caching, not when a dispatch names it: an unchecked // block steers the prefix, and by then the damage is a halt. - if accept_peer_block(&block, peer_zone, expected_pubkey) { + if accept_peer_block(&block, peer_zone, expected_pubkeys) { peers.insert(peer_zone, block, slot).await; } } @@ -1270,8 +1255,8 @@ where /// hashing differently is reported by name, an endpoint fault per the gate in /// [`CrossZoneVerifier::wait_for_peer_block`]. /// -/// When `pinned` holds the zone's block-signing key, a candidate that is not -/// signed by it is skipped like a wrong id. The hash preimage excludes the +/// When `pinned` holds the zone's block-signing keys, a candidate signed by +/// none of them is skipped like a wrong id. The hash preimage excludes the /// signature, so a same-content twin with a corrupted signature would pass the /// hash check here only to halt ingestion as Forged at [`rederive`]'s /// pinned-key gate; skipping it lets the honest copy later in the slot match, @@ -1283,7 +1268,7 @@ async fn refetched_block( read_at: Slot, block_id: u64, block_hash: HashType, - pinned: Option<&PublicKey>, + pinned: &[PublicKey], ) -> anyhow::Result where S: Stream, @@ -1306,7 +1291,7 @@ where if block.header.block_id != block_id { continue; } - if pinned.is_some_and(|key| !block.is_signed_by(key)) { + if !signed_by_any(&block, pinned) { continue; } let recomputed = block.recompute_hash(); @@ -1353,7 +1338,9 @@ mod tests { verifier_with_pinned_keys(HashMap::new()) } - fn verifier_with_pinned_keys(peer_pubkeys: HashMap) -> CrossZoneVerifier { + fn verifier_with_pinned_keys( + peer_pubkeys: HashMap>, + ) -> CrossZoneVerifier { CrossZoneVerifier { self_zone: SELF_ZONE, peer_pubkeys, @@ -1529,7 +1516,7 @@ mod tests { // produce_dummy_block signs with PrivateKey([37; 32]); pin its pubkey. let signer = PublicKey::new_from_private_key(&PrivateKey::try_new([37; 32]).unwrap()); let mut keys = HashMap::new(); - keys.insert(PEER_ZONE, signer); + keys.insert(PEER_ZONE, vec![signer]); let verifier = verifier_with_pinned_keys(keys); cache_chain(&verifier, peer_chain(b"hi")).await; @@ -1544,7 +1531,52 @@ mod tests { async fn rejects_dispatch_from_a_block_not_signed_by_the_pinned_key() { // Pin a different key than the one that signed the peer block. let mut keys = HashMap::new(); - keys.insert(PEER_ZONE, PublicKey::try_new([42; 32]).unwrap()); + keys.insert(PEER_ZONE, vec![PublicKey::try_new([42; 32]).unwrap()]); + let verifier = verifier_with_pinned_keys(keys); + cache_chain(&verifier, peer_chain(b"hi")).await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + let err = verifier + .verify_block(&block, Slot::from(0)) + .await + .unwrap_err(); + assert!( + err.to_string().contains("pinned"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn verifies_dispatch_signed_by_any_key_in_the_pinned_set() { + // The multi-sequencer peer shape: the block's signer is one configured + // key among several, not the first one listed. + let signer = PublicKey::new_from_private_key(&PrivateKey::try_new([37; 32]).unwrap()); + let mut keys = HashMap::new(); + keys.insert( + PEER_ZONE, + vec![PublicKey::try_new([42; 32]).unwrap(), signer], + ); + let verifier = verifier_with_pinned_keys(keys); + cache_chain(&verifier, peer_chain(b"hi")).await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&block, Slot::from(0)) + .await + .expect("any listed key admits the source block"); + } + + #[tokio::test] + async fn rejects_dispatch_from_a_block_signed_by_no_key_in_the_set() { + // Neither pinned key signed the peer block. + let mut keys = HashMap::new(); + keys.insert( + PEER_ZONE, + vec![ + PublicKey::try_new([42; 32]).unwrap(), + PublicKey::new_from_private_key(&PrivateKey::try_new([99; 32]).unwrap()), + ], + ); let verifier = verifier_with_pinned_keys(keys); cache_chain(&verifier, peer_chain(b"hi")).await; @@ -1661,7 +1693,7 @@ mod tests { peer_block_msg(&chain[1], 1), ]); - let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None).await; + let pass = consume_peer_stream(stream, PEER_ZONE, &[], &peers, None).await; assert_eq!(pass.cursor, Some(Slot::from(1))); assert_eq!(pass.stalled_at, None); @@ -1754,7 +1786,7 @@ mod tests { peer_block_msg(&chain[2], 2), ]); - let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None).await; + let pass = consume_peer_stream(stream, PEER_ZONE, &[], &peers, None).await; assert_eq!(pass.cursor, Some(Slot::from(0))); assert_eq!(pass.stalled_at, Some(Slot::from(1))); @@ -1769,7 +1801,7 @@ mod tests { // One slot can carry several messages; the second one fails. let stream = stream::iter(vec![peer_block_msg(&chain[0], 7), undecodable_msg(7)]); - let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, Some(Slot::from(6))).await; + let pass = consume_peer_stream(stream, PEER_ZONE, &[], &peers, Some(Slot::from(6))).await; // Slot 7 is re-read whole next pass, not resumed past the failure. assert_eq!(pass.cursor, Some(Slot::from(6))); @@ -1788,7 +1820,7 @@ mod tests { peer_block_msg(&chain[2], 2), ]); - let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None).await; + let pass = consume_peer_stream(stream, PEER_ZONE, &[], &peers, None).await; assert_eq!(pass.cursor, Some(Slot::from(0)), "the slot is held"); assert_eq!(pass.stalled_at, Some(Slot::from(1))); @@ -1808,7 +1840,7 @@ mod tests { undecodable_msg(1), peer_block_msg(&chain[2], 2), ]); - consume_peer_stream(stream, PEER_ZONE, None, &verifier.peers, None).await; + consume_peer_stream(stream, PEER_ZONE, &[], &verifier.peers, None).await; // Regression: block 2 used to be reported as forged, halting ingestion // permanently, because a `max(cached ids)` high-water mark counted @@ -1894,7 +1926,7 @@ mod tests { let pass = consume_peer_stream( stream::iter(vec![undecodable_msg(0)]), PEER_ZONE, - None, + &[], &verifier.peers, None, ) @@ -1908,7 +1940,7 @@ mod tests { peer_block_msg(block, u64::try_from(index).expect("test index fits in u64")) })), PEER_ZONE, - None, + &[], &verifier.peers, pass.cursor, ) @@ -2151,7 +2183,7 @@ mod tests { let pass = consume_peer_stream( stream::iter(vec![peer_block_msg(&tampered, 0)]), PEER_ZONE, - None, + &[], &verifier.peers, None, ) @@ -2178,7 +2210,7 @@ mod tests { let pass = consume_peer_stream( stream::iter(vec![peer_block_msg(&block, 0)]), PEER_ZONE, - Some(&wrong_key), + &[wrong_key], &verifier.peers, None, ) @@ -2196,7 +2228,7 @@ mod tests { consume_peer_stream( stream::iter(vec![peer_block_msg(&block, 1)]), PEER_ZONE, - Some(&signer), + &[signer], &verifier.peers, None, ) @@ -2520,7 +2552,7 @@ mod tests { peer_block_msg(target, 4), ]); - let block = refetched_block(stream, Slot::from(4), 2, target.header.hash, None) + let block = refetched_block(stream, Slot::from(4), 2, target.header.hash, &[]) .await .expect("the recorded slot serves the block"); assert_eq!(block.header.hash, target.header.hash); @@ -2535,7 +2567,7 @@ mod tests { tampered.header.hash = HashType([0xAB; 32]); let stream = stream::iter(vec![peer_block_msg(&tampered, 4)]); - let block = refetched_block(stream, Slot::from(4), GENESIS_BLOCK_ID, certified, None) + let block = refetched_block(stream, Slot::from(4), GENESIS_BLOCK_ID, certified, &[]) .await .expect("the recomputed hash matches the index"); assert_eq!( @@ -2602,7 +2634,7 @@ mod tests { let served = produce_dummy_block(2, Some(chain[0].header.hash), vec![emission(b"other")]); let stream = stream::iter(vec![peer_block_msg(&served, 4)]); - let err = refetched_block(stream, Slot::from(4), 2, chain[1].header.hash, None) + let err = refetched_block(stream, Slot::from(4), 2, chain[1].header.hash, &[]) .await .expect_err("different bytes than the walk saw must be refused"); assert!( @@ -2626,7 +2658,7 @@ mod tests { Slot::from(4), GENESIS_BLOCK_ID, honest.header.hash, - Some(&signer), + std::slice::from_ref(&signer), ) .await .expect("the honest copy later in the slot matches"); @@ -2650,7 +2682,7 @@ mod tests { Slot::from(4), GENESIS_BLOCK_ID, honest.header.hash, - Some(&signer), + std::slice::from_ref(&signer), ) .await .expect_err("a slot with no validly signed copy has no block to return"); @@ -2667,7 +2699,7 @@ mod tests { Slot::from(4), 2, HashType([1; 32]), - None, + &[], ) .await .expect_err("nothing at the slot decodes to the block"); diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index 9184e0342..31d881997 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -18,9 +18,6 @@ const SOURCE_MARKER_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneSource/00000/" /// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. pub type ZoneId = [u8; 32]; -/// Block-signing public key pinned per peer zone. -pub type ExpectedPubkey = [u8; 32]; - /// Content-addressed replay key for a delivered message. pub type MessageKey = [u8; 32]; @@ -41,24 +38,30 @@ pub struct CrossZoneRoute { } /// A peer zone whose outbox a zone watches for inbound cross-zone messages. +/// +/// Unknown fields are refused so a stale or misspelled key in an operator +/// config fails startup instead of silently pinning nothing. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CrossZonePeer { /// The peer's Bedrock channel; its 32 bytes double as the peer's zone id. pub channel_id: ZoneId, /// The deliveries this peer may make: which of its programs may emit, and /// what each of them may reach here. pub allowed_routes: Vec, - /// The peer's block-signing public key, pinned to reject blocks inscribed by - /// anyone other than that zone's sequencer. `None` skips the check (the - /// channel signer is still authenticated by the zone-sdk). + /// The peer's block-signing public keys, pinned to reject blocks inscribed + /// by anyone other than that zone's sequencers: a block is acceptable when + /// signed by any of them, one entry per sequencer. Empty skips the check + /// (the channel signer is still authenticated by the zone-sdk). #[serde(default)] - pub expected_block_signing_pubkey: Option<[u8; 32]>, + pub expected_block_signing_pubkeys: Vec<[u8; 32]>, } /// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer /// (verifier): the peers it reads from Bedrock and, per peer, the local programs /// they may deliver to. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CrossZoneConfig { pub peers: Vec, } diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index 4d6f30147..357d4a6a8 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use common::{HashType, block::Block, transaction::LeeTransaction}; use cross_zone::{ EmissionSource, Link, StallState, alerts_at, build_dispatch_from_emission, equivocation_report, - extract_emission, is_sequencer_only_program, link_to_tip, screen_peer_block, + extract_emission, is_sequencer_only_program, link_to_tip, pinned_keys, screen_peer_block, }; use cross_zone_inbox_core::message_key; use futures::{Stream, StreamExt as _}; @@ -30,7 +30,7 @@ use crate::{ struct PeerContext { peer_zone: [u8; 32], self_zone: [u8; 32], - expected_pubkey: Option, + expected_pubkeys: Vec, } /// Why one pass over a peer's stream ended. @@ -147,15 +147,13 @@ pub fn spawn_watchers( CommonHttpClient::new(bedrock_config.auth.clone().map(Into::into)), bedrock_config.node_url.clone(), ); - let expected_pubkey = peer.expected_block_signing_pubkey.map(|bytes| { - PublicKey::try_new(bytes).expect("configured peer block-signing pubkey is a valid key") - }); + let expected_pubkeys = pinned_keys(&peer); tasks.push(tokio::spawn(watch_peer( ZoneIndexer::new(ChannelId::from(peer.channel_id), node), PeerContext { peer_zone: peer.channel_id, self_zone, - expected_pubkey, + expected_pubkeys, }, poll_interval, Arc::clone(dbio), @@ -332,7 +330,7 @@ where // block this watcher cannot place is read past rather than // treated as the end of the chain: the peer's own next honest // block still links to the tip. - let link = match screen_peer_block(&block, peer.expected_pubkey.as_ref()) { + let link = match screen_peer_block(&block, &peer.expected_pubkeys) { Ok(recomputed) => link_to_tip(tip.as_ref(), &block, recomputed), Err(refusal) => { skipped = skipped.saturating_add(1); @@ -577,7 +575,7 @@ mod tests { PeerContext { peer_zone: PEER_ZONE, self_zone: SELF_ZONE, - expected_pubkey: None, + expected_pubkeys: Vec::new(), } } @@ -1229,6 +1227,70 @@ mod tests { assert_eq!(tip, Some(tip_at(2))); } + #[tokio::test] + async fn watcher_delivers_from_a_block_signed_by_any_pinned_key() { + // The multi-sequencer peer shape: the block's signer is one configured + // key among several, not the first one listed. + let (_dir, dbio) = store(); + let mut cursor = None; + let mut tip = None; + let signer = + PublicKey::new_from_private_key(&lee::PrivateKey::try_new([37; 32]).expect("test key")); + let peer = PeerContext { + expected_pubkeys: vec![PublicKey::try_new([42; 32]).expect("test key"), signer], + ..peer_context() + }; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 0)]), + &peer, + &dbio, + &mut cursor, + &mut tip, + ) + .await; + + assert_eq!(outcome, PassOutcome::Drained); + assert_eq!( + recorded_keys(&dbio), + vec![message_key(&PEER_ZONE, 1, 0)], + "any listed key admits the block, whatever its position" + ); + assert_eq!(tip, Some(tip_at(1))); + } + + #[tokio::test] + async fn watcher_skips_a_block_signed_by_no_pinned_key() { + let (_dir, dbio) = store(); + let mut cursor = None; + let mut tip = None; + let peer = PeerContext { + expected_pubkeys: vec![ + PublicKey::try_new([42; 32]).expect("test key"), + PublicKey::new_from_private_key( + &lee::PrivateKey::try_new([99; 32]).expect("test key"), + ), + ], + ..peer_context() + }; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 0)]), + &peer, + &dbio, + &mut cursor, + &mut tip, + ) + .await; + + assert_eq!(outcome, PassOutcome::Stranded); + assert!( + recorded_keys(&dbio).is_empty(), + "a block signed by none of the pinned keys is never delivered from" + ); + assert_eq!(tip, None, "a screened-out block does not advance the tip"); + } + #[tokio::test] async fn a_watcher_with_no_tip_delivers_nothing_below_the_peers_genesis() { // A fresh watcher handed a mid-chain block has nothing to link it diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 7e5bc0f76..78d65f260 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -183,7 +183,7 @@ fn cross_zone_test_config() -> SequencerConfig { src_program_id: programs::ping_sender().id(), target_program_id: programs::ping_receiver().id(), }], - expected_block_signing_pubkey: None, + expected_block_signing_pubkeys: Vec::new(), }], }), ..setup_sequencer_config() diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 0fe43eded..941ca0005 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index 83962eaf8..a31be9b9b 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -373,7 +373,7 @@ fn watch_peer(peer: ZoneId, receiver_id: ProgramId) -> CrossZoneConfig { src_program_id: programs::ping_sender().id(), target_program_id: receiver_id, }], - expected_block_signing_pubkey: None, + expected_block_signing_pubkeys: Vec::new(), }], } }