feat(cross-zone): pin peer block-signing keys and distinguish verifier lag from forgery

This commit is contained in:
moudyellaz 2026-06-24 11:23:12 +02:00
parent fef7ce2c38
commit ff7610804a
7 changed files with 177 additions and 35 deletions

View File

@ -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,
}],
};

View File

@ -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,
}],
};

View File

@ -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,
}],
};

View File

@ -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<Block> 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};

View File

@ -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<u64> {
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<ZoneId, PublicKey>,
peers: PeerBlocks,
seen: Arc<RwLock<HashSet<MessageKey>>>,
}
@ -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<lee::PublicTransaction> {
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<Block> {
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<ZoneId, PublicKey>) -> 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();

View File

@ -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<NodeHttpClient>,
peer_zone: [u8; 32],
allowed_targets: Vec<ProgramId>,
expected_pubkey: Option<PublicKey>,
self_zone: [u8; 32],
inbox_id: ProgramId,
ping_sender_id: ProgramId,
@ -90,17 +95,31 @@ async fn watch_peer(
};
match borsh::from_slice::<Block>(&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}"),
}

View File

@ -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<ProgramId>,
/// 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