From ff7610804a832846f486a01aa19d505ba69e4411 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Wed, 24 Jun 2026 11:23:12 +0200 Subject: [PATCH] feat(cross-zone): pin peer block-signing keys and distinguish verifier lag from forgery --- integration_tests/tests/cross_zone_bridge.rs | 1 + integration_tests/tests/cross_zone_ping.rs | 1 + .../tests/cross_zone_verified.rs | 1 + lez/common/src/block.rs | 25 +++- lez/indexer/core/src/cross_zone_verifier.rs | 136 +++++++++++++++--- lez/sequencer/core/src/cross_zone_watcher.rs | 43 ++++-- programs/cross_zone_inbox/core/src/lib.rs | 5 + 7 files changed, 177 insertions(+), 35 deletions(-) diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 689558ad..84eb403b 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -54,6 +54,7 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { peers: vec![CrossZonePeer { channel_id: *channel_a.as_ref(), allowed_targets: vec![wrapped_token_id], + expected_block_signing_pubkey: None, }], }; diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index 3afeeb39..6f4283ab 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -51,6 +51,7 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { peers: vec![CrossZonePeer { channel_id: zone_a, allowed_targets: vec![receiver_id], + expected_block_signing_pubkey: None, }], }; diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index e25312eb..6787946e 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -48,6 +48,7 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { peers: vec![CrossZonePeer { channel_id: zone_a, allowed_targets: vec![receiver_id], + expected_block_signing_pubkey: None, }], }; diff --git a/lez/common/src/block.rs b/lez/common/src/block.rs index 6e956f9f..b7c3e235 100644 --- a/lez/common/src/block.rs +++ b/lez/common/src/block.rs @@ -76,11 +76,13 @@ pub struct HashableBlockData { } impl HashableBlockData { + /// The hash a block's signature is computed over. Signing and verifying both + /// go through this, so a peer's block-signing key can be re-checked. #[must_use] - pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block { + pub fn signing_hash(&self) -> BlockHash { const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Block/\x00\x00\x00\x00\x00\x00\x00\x00"; - let data_bytes = borsh::to_vec(&self).unwrap(); + let data_bytes = borsh::to_vec(self).unwrap(); let mut bytes = Vec::with_capacity( PREFIX .len() @@ -90,7 +92,12 @@ impl HashableBlockData { bytes.extend_from_slice(PREFIX); bytes.extend_from_slice(&data_bytes); - let hash = OwnHasher::hash(&bytes); + OwnHasher::hash(&bytes) + } + + #[must_use] + pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block { + let hash = self.signing_hash(); let signature = lee::Signature::new(signing_key, &hash.0); Block { header: BlockHeader { @@ -119,6 +126,18 @@ impl From for HashableBlockData { } } +impl Block { + /// Recomputes the signed hash from the block contents and checks the header + /// signature against `expected_pubkey`. Used to pin a peer zone's + /// block-signing key, so a block inscribed by anyone other than that zone's + /// sequencer is rejected even if it reached the channel. + #[must_use] + pub fn is_signed_by(&self, expected_pubkey: &lee::PublicKey) -> bool { + let hash = HashableBlockData::from(self.clone()).signing_hash(); + self.header.signature.is_valid_for(&hash.0, expected_pubkey) + } +} + #[cfg(test)] mod tests { use crate::{HashType, block::HashableBlockData, test_utils}; diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index cf0cc4d4..4dd5ce0d 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -4,14 +4,14 @@ use std::{ time::Duration, }; -use anyhow::{Context as _, Result, bail}; +use anyhow::{Result, bail}; use common::{block::Block, transaction::LeeTransaction}; use cross_zone_inbox_core::{ CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, build_dispatch_from_emission, extract_emission, message_key, }; use futures::StreamExt as _; -use lee::program::Program; +use lee::{PublicKey, program::Program}; use lee_core::program::ProgramId; use log::{error, info}; use logos_blockchain_core::mantle::ops::channel::ChannelId; @@ -22,9 +22,9 @@ use tokio::sync::RwLock; use crate::config::IndexerConfig; -/// How long the verifier waits for a referenced peer block to finalize before -/// rejecting the dispatch as referencing a nonexistent block. -const PEER_BLOCK_WAIT: Duration = Duration::from_secs(60); +/// How often the verifier logs that it is still waiting on a lagging peer reader, +/// so a stuck wait is observable without rejecting a legitimate message. +const LAG_LOG_INTERVAL: Duration = Duration::from_secs(30); /// Cache of finalized peer-zone blocks, filled by per-peer reader tasks and read /// by the verifier to re-derive cross-zone dispatch transactions. @@ -50,6 +50,17 @@ impl PeerBlocks { .get(&zone) .and_then(|chain| chain.get(&block_id).cloned()) } + + /// The highest block id this reader has finalized for `zone`, or `None` if it + /// has read nothing yet. Used to tell forgery (we have read past the + /// referenced block and it is absent) from lag (we simply have not caught up). + async fn highest_seen(&self, zone: ZoneId) -> Option { + self.chains + .read() + .await + .get(&zone) + .and_then(|chain| chain.keys().copied().max()) + } } /// The indexer-side Option B verifier. For every cross-zone dispatch in a block @@ -62,6 +73,8 @@ pub struct CrossZoneVerifier { inbox_id: ProgramId, ping_sender_id: ProgramId, bridge_lock_id: ProgramId, + /// Pinned block-signing key per peer zone, enforced during re-derivation. + peer_pubkeys: HashMap, peers: PeerBlocks, seen: Arc>>, } @@ -73,12 +86,18 @@ impl CrossZoneVerifier { let cross_zone = config.cross_zone.as_ref()?; let self_zone: ZoneId = *config.channel_id.as_ref(); let peers = PeerBlocks::default(); + let mut peer_pubkeys = HashMap::new(); for peer in &cross_zone.peers { let node = NodeHttpClient::new( CommonHttpClient::new(config.bedrock_config.auth.clone().map(Into::into)), config.bedrock_config.addr.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); + } tokio::spawn(read_peer( ZoneIndexer::new(ChannelId::from(peer.channel_id), node), peer.channel_id, @@ -92,6 +111,7 @@ impl CrossZoneVerifier { inbox_id: Program::cross_zone_inbox().id(), ping_sender_id: Program::ping_sender().id(), bridge_lock_id: Program::bridge_lock().id(), + peer_pubkeys, peers, seen: Arc::new(RwLock::new(HashSet::new())), }) @@ -153,14 +173,19 @@ impl CrossZoneVerifier { async fn rederive(&self, msg: &CrossZoneMessage) -> Result { let peer_block = self .wait_for_peer_block(msg.src_zone, msg.src_block_id) - .await - .with_context(|| { - format!( - "no peer block {} from zone {} to verify against", - msg.src_block_id, - hex::encode(msg.src_zone) - ) - })?; + .await?; + + // 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) { + if !peer_block.is_signed_by(expected) { + bail!( + "forged cross-zone dispatch: peer zone {} block {} is not signed by the pinned block-signing key", + hex::encode(msg.src_zone), + msg.src_block_id + ); + } + } let emission_tx = peer_block .body @@ -200,20 +225,35 @@ impl CrossZoneVerifier { )) } - /// Polls the peer cache until the referenced block finalizes. A forged - /// reference to a never-finalized block times out and is rejected. + /// Resolves the referenced peer block, distinguishing forgery from lag. + /// + /// If the block is cached, return it. If our peer reader has already + /// finalized past `block_id` and we still do not have it, the reference is to + /// a block that does not exist on the peer chain, a forgery, so reject now. + /// Otherwise the reader simply has not caught up yet: keep waiting, since a + /// legitimate dispatch is only injected after its peer block finalized and + /// our reader of the same finalized chain will see it too. Rejecting on a + /// timeout here would turn a lagging reader into a permanent halt of an + /// honest message. async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result { let mut waited = Duration::ZERO; loop { if let Some(block) = self.peers.get(zone, block_id).await { return Ok(block); } - if waited >= PEER_BLOCK_WAIT { + if self.peers.highest_seen(zone).await.is_some_and(|h| h >= block_id) { bail!( - "peer block {} from zone {} did not finalize within {:?}", - block_id, + "forged cross-zone reference: peer zone {} finalized past block {} but it is absent", hex::encode(zone), - PEER_BLOCK_WAIT + block_id + ); + } + if !waited.is_zero() && waited.as_secs() % LAG_LOG_INTERVAL.as_secs() == 0 { + info!( + "Waiting for peer zone {} to finalize block {} ({}s); reader is behind", + hex::encode(zone), + block_id, + waited.as_secs() ); } tokio::time::sleep(Duration::from_secs(1)).await; @@ -264,7 +304,7 @@ async fn read_peer( mod tests { use common::test_utils::produce_dummy_block; use lee::{ - PublicTransaction, + PrivateKey, PublicKey, PublicTransaction, program::Program, public_transaction::{Message, WitnessSet}, }; @@ -277,11 +317,16 @@ mod tests { const PEER_BLOCK_ID: u64 = 5; fn verifier() -> CrossZoneVerifier { + verifier_with_pinned_keys(HashMap::new()) + } + + fn verifier_with_pinned_keys(peer_pubkeys: HashMap) -> CrossZoneVerifier { CrossZoneVerifier { self_zone: SELF_ZONE, inbox_id: Program::cross_zone_inbox().id(), ping_sender_id: Program::ping_sender().id(), bridge_lock_id: Program::bridge_lock().id(), + peer_pubkeys, peers: PeerBlocks::default(), seen: Arc::new(RwLock::new(HashSet::new())), } @@ -351,6 +396,57 @@ mod tests { assert!(err.to_string().contains("forged"), "unexpected error: {err}"); } + #[tokio::test] + async fn verifies_dispatch_signed_by_the_pinned_peer_key() { + // 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); + let verifier = verifier_with_pinned_keys(keys); + verifier + .peers + .insert(PEER_ZONE, produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")])) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&block) + .await + .expect("a dispatch from the pinned signer verifies"); + } + + #[tokio::test] + 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()); + let verifier = verifier_with_pinned_keys(keys); + verifier + .peers + .insert(PEER_ZONE, produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")])) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + let err = verifier.verify_block(&block).await.unwrap_err(); + assert!(err.to_string().contains("pinned"), "unexpected error: {err}"); + } + + #[tokio::test] + async fn rejects_reference_to_a_block_the_peer_never_finalized() { + let verifier = verifier(); + // The reader has finalized past PEER_BLOCK_ID (it holds a later block) but + // never saw PEER_BLOCK_ID itself, so a dispatch referencing it is a forgery + // and must be rejected rather than waited on forever. + verifier + .peers + .insert(PEER_ZONE, produce_dummy_block(PEER_BLOCK_ID + 1, None, vec![emission(b"hi")])) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + let err = verifier.verify_block(&block).await.unwrap_err(); + assert!(err.to_string().contains("forged"), "unexpected error: {err}"); + } + #[tokio::test] async fn rejects_replayed_dispatch() { let verifier = verifier(); diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index 2885ae48..c76c7a55 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::time::Duration; use common::{block::Block, transaction::LeeTransaction}; use cross_zone_inbox_core::{build_dispatch_from_emission, extract_emission}; use futures::StreamExt as _; -use lee::program::Program; +use lee::{PublicKey, program::Program}; use lee_core::program::ProgramId; use log::{error, info, warn}; use logos_blockchain_core::mantle::ops::channel::ChannelId; @@ -37,10 +37,14 @@ 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") + }); tokio::spawn(watch_peer( ZoneIndexer::new(ChannelId::from(peer.channel_id), node), peer.channel_id, peer.allowed_targets, + expected_pubkey, self_zone, inbox_id, ping_sender_id, @@ -59,6 +63,7 @@ async fn watch_peer( zone_indexer: ZoneIndexer, peer_zone: [u8; 32], allowed_targets: Vec, + expected_pubkey: Option, self_zone: [u8; 32], inbox_id: ProgramId, ping_sender_id: ProgramId, @@ -90,17 +95,31 @@ async fn watch_peer( }; match borsh::from_slice::(&zone_block.data) { Ok(block) => { - deliver_block( - &block, - peer_zone, - self_zone, - inbox_id, - ping_sender_id, - bridge_lock_id, - &allowed_targets, - &mempool_handle, - ) - .await; + // Reject blocks not signed by the pinned peer key (equivocation): + // the channel signer is authenticated by the zone-sdk, but that + // does not prove the peer's honest sequencer produced the block. + if expected_pubkey + .as_ref() + .is_some_and(|pk| !block.is_signed_by(pk)) + { + warn!( + "Watcher dropping peer {} block {}: block-signing key does not match the pinned key", + hex::encode(peer_zone), + block.header.block_id + ); + } else { + deliver_block( + &block, + peer_zone, + self_zone, + inbox_id, + ping_sender_id, + bridge_lock_id, + &allowed_targets, + &mempool_handle, + ) + .await; + } } Err(err) => error!("Watcher failed to deserialize peer block: {err}"), } diff --git a/programs/cross_zone_inbox/core/src/lib.rs b/programs/cross_zone_inbox/core/src/lib.rs index 3d9ea577..c6b3e674 100644 --- a/programs/cross_zone_inbox/core/src/lib.rs +++ b/programs/cross_zone_inbox/core/src/lib.rs @@ -30,6 +30,11 @@ pub struct CrossZonePeer { pub channel_id: ZoneId, /// Programs on the local zone a message from this peer is allowed to target. pub allowed_targets: 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). + #[serde(default)] + pub expected_block_signing_pubkey: Option<[u8; 32]>, } /// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer