mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-06-29 18:39:30 +00:00
feat(cross-zone): pin peer block-signing keys and distinguish verifier lag from forgery
This commit is contained in:
parent
fef7ce2c38
commit
ff7610804a
@ -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,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}"),
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user