From 124fa6b9efa0b9621c6ba033caee17ac36372c0f Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Tue, 13 Oct 2026 14:19:58 -0400 Subject: [PATCH 1/2] chore: drand protobuf and drand fake signer --- .gitattributes | 1 + proto/drand_pb.proto | 9 +++ src/beacon/drand_pb.rs | 55 ++++++++++++++ src/beacon/mod.rs | 6 ++ src/beacon/signatures/mod.rs | 2 +- src/beacon/tests/fake_drand_signer.rs | 100 ++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 proto/drand_pb.proto create mode 100644 src/beacon/drand_pb.rs create mode 100644 src/beacon/tests/fake_drand_signer.rs diff --git a/.gitattributes b/.gitattributes index d6ae18d4171a..0a2aa1e62704 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ assets/actor_bundles.car.zst filter=lfs diff=lfs merge=lfs -text +src/beacon/drand_pb.rs linguist-generated=true diff --git a/proto/drand_pb.proto b/proto/drand_pb.proto new file mode 100644 index 000000000000..74319bd75a4b --- /dev/null +++ b/proto/drand_pb.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package drand_pb; + +// https://github.com/drand/drand/blob/v2.1.7/protobuf/drand/api.proto#L42-L53 +message PublicRandResponse { + uint64 round = 1; + bytes signature = 2; +} diff --git a/src/beacon/drand_pb.rs b/src/beacon/drand_pb.rs new file mode 100644 index 000000000000..3447b19f9cb6 --- /dev/null +++ b/src/beacon/drand_pb.rs @@ -0,0 +1,55 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT +// Automatically generated rust module for 'drand_pb.proto' file +// Command: `pb-rs -s -D proto/drand_pb.proto`, See + +#![allow(non_snake_case)] +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(unused_imports)] +#![allow(unknown_lints)] +#![allow(clippy::all)] +#![allow(clippy::assigning_clones)] +#![cfg_attr(rustfmt, rustfmt_skip)] + + +use quick_protobuf::{MessageInfo, MessageRead, MessageWrite, BytesReader, Writer, WriterBackend, Result}; +use quick_protobuf::sizeofs::*; +use super::*; + +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Debug, Default, PartialEq, Clone)] +pub struct PublicRandResponse { + pub round: u64, + pub signature: Vec, +} + +impl<'a> MessageRead<'a> for PublicRandResponse { + fn from_reader(r: &mut BytesReader, bytes: &'a [u8]) -> Result { + let mut msg = Self::default(); + while !r.is_eof() { + match r.next_tag(bytes) { + Ok(8) => msg.round = r.read_uint64(bytes)?, + Ok(18) => msg.signature = r.read_bytes(bytes)?.to_owned(), + Ok(t) => { r.read_unknown(bytes, t)?; } + Err(e) => return Err(e), + } + } + Ok(msg) + } +} + +impl MessageWrite for PublicRandResponse { + fn get_size(&self) -> usize { + 0 + + if self.round == 0u64 { 0 } else { 1 + sizeof_varint(*(&self.round) as u64) } + + if self.signature.is_empty() { 0 } else { 1 + sizeof_len((&self.signature).len()) } + } + + fn write_message(&self, w: &mut Writer) -> Result<()> { + if self.round != 0u64 { w.write_with_tag(8, |w| w.write_uint64(*&self.round))?; } + if !self.signature.is_empty() { w.write_with_tag(18, |w| w.write_bytes(&**&self.signature))?; } + Ok(()) + } +} + diff --git a/src/beacon/mod.rs b/src/beacon/mod.rs index 0e594766e4ad..40ba80b32617 100644 --- a/src/beacon/mod.rs +++ b/src/beacon/mod.rs @@ -3,10 +3,15 @@ pub mod beacon_entries; mod drand; +// Only test fixtures decode it until gossiped entries are validated. +#[cfg(test)] +mod drand_pb; pub mod metrics; pub mod signatures; pub use beacon_entries::*; pub use drand::*; +#[cfg(test)] +pub use drand_pb::PublicRandResponse; #[cfg(test)] pub mod mock_beacon; @@ -16,4 +21,5 @@ pub mod tests { // tests in other modules. pub mod drand; pub mod fake_drand_server; + pub mod fake_drand_signer; } diff --git a/src/beacon/signatures/mod.rs b/src/beacon/signatures/mod.rs index 19cb9b90d456..f17913f46a3b 100644 --- a/src/beacon/signatures/mod.rs +++ b/src/beacon/signatures/mod.rs @@ -13,7 +13,7 @@ use rayon::prelude::*; pub use bls_signatures::{PublicKey as PublicKeyOnG1, Signature as SignatureOnG2}; // See -const CSUITE_G1: &[u8] = b"BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; +pub(in crate::beacon) const CSUITE_G1: &[u8] = b"BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; const CSUITE_G2: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_"; #[derive(Debug, Clone, Eq, PartialEq, derive_more::Deref)] diff --git a/src/beacon/tests/fake_drand_signer.rs b/src/beacon/tests/fake_drand_signer.rs new file mode 100644 index 000000000000..fdf68c919cb9 --- /dev/null +++ b/src/beacon/tests/fake_drand_signer.rs @@ -0,0 +1,100 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use crate::beacon::{ + Beacon, BeaconEntry, ChainInfo, DrandBeacon, DrandConfig, DrandNetwork, PublicRandResponse, +}; +use crate::utils::encoding::hex; +use blstrs::{G1Projective, G2Projective, Scalar}; +use group::{Curve, Group}; + +const FAKE_DRAND_GENESIS_TIME: i32 = 1_692_803_367; +const FAKE_DRAND_PERIOD: i32 = 3; + +const TEST_FIL_GENESIS_TIME: u64 = 1_598_306_400; +const TEST_FIL_BLOCK_DELAY: u64 = 30; + +pub struct FakeDrandSigner { + secret: Scalar, + config: DrandConfig<'static>, +} + +impl FakeDrandSigner { + pub fn new() -> Self { + let secret = Scalar::from(0xC0FFEEu64); + let public = G2Projective::generator() * secret; + let public_key = hex::encode(public.to_affine().to_compressed()); + Self { + secret, + config: DrandConfig { + servers: vec![], + chain_info: ChainInfo { + public_key: public_key.into(), + period: FAKE_DRAND_PERIOD, + genesis_time: FAKE_DRAND_GENESIS_TIME, + hash: "0011".repeat(16).into(), + group_hash: "00".repeat(32).into(), + }, + network_type: DrandNetwork::Quicknet, // unchained + // Would clash with the real quicknet config's collector. + register_metrics: false, + }, + } + } + + // sign H(round) on G1, exactly what `verify_entries` checks for unchained. + pub fn entry(&self, round: u64) -> BeaconEntry { + let msg = BeaconEntry::message_unchained(round); + let point = + G1Projective::hash_to_curve(msg.as_ref(), crate::beacon::signatures::CSUITE_G1, &[]); + let point = point * self.secret; + BeaconEntry::new(round, point.to_affine().to_compressed().to_vec()) + } + + // encode PublicRandResponse to protobuf + pub fn to_protobuf(&self, round: u64) -> Vec { + self.to_protobuf_raw(round, self.entry(round).signature()) + } + + /// A `PublicRandResponse` payload with any signature. + pub fn to_protobuf_raw(&self, round: u64, signature: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut w = quick_protobuf::Writer::new(&mut out); + quick_protobuf::MessageWrite::write_message( + &PublicRandResponse { + round, + signature: signature.to_vec(), + }, + &mut w, + ) + .unwrap(); + out + } + + pub fn beacon(&self) -> DrandBeacon { + DrandBeacon::new(TEST_FIL_GENESIS_TIME, TEST_FIL_BLOCK_DELAY, &self.config) + } +} + +// Entries decoded from the gossip encoding verify against the fake chain's key. +#[test] +fn fake_drand_entries_verify() { + let d = FakeDrandSigner::new(); + let entries: Vec<_> = (1..=5) + .map(|r| { + let payload = d.to_protobuf(r); + let mut reader = quick_protobuf::BytesReader::from_bytes(&payload); + let response = ::from_reader( + &mut reader, + &payload, + ) + .unwrap(); + BeaconEntry::new(response.round, response.signature) + }) + .collect(); + assert!( + d.beacon() + .verify_entries(&entries, &BeaconEntry::default()) + .unwrap() + ); +} From a129a488b8cab85322784d65d5f6aeb95fc30748 Mon Sep 17 00:00:00 2001 From: EclesioMeloJunior Date: Thu, 15 Oct 2026 16:49:36 -0400 Subject: [PATCH 2/2] chore: address comments --- proto/drand_pb.proto | 4 ++ src/beacon/mod.rs | 2 - src/beacon/signatures/mod.rs | 8 ++- src/beacon/tests/drand.rs | 12 +++-- src/beacon/tests/fake_drand_signer.rs | 76 +++++++++++++-------------- 5 files changed, 55 insertions(+), 47 deletions(-) diff --git a/proto/drand_pb.proto b/proto/drand_pb.proto index 74319bd75a4b..e51f379b4fbe 100644 --- a/proto/drand_pb.proto +++ b/proto/drand_pb.proto @@ -3,6 +3,10 @@ syntax = "proto3"; package drand_pb; // https://github.com/drand/drand/blob/v2.1.7/protobuf/drand/api.proto#L42-L53 +// Only the fields an unchained beacon entry needs. The rest are skipped on decode, as +// protobuf skips unknown fields: `previous_signature` is empty on unchained networks, +// `randomness` is deprecated and derived from the signature, and `metadata` names the beacon, +// which the gossip topic already identifies. message PublicRandResponse { uint64 round = 1; bytes signature = 2; diff --git a/src/beacon/mod.rs b/src/beacon/mod.rs index 40ba80b32617..30d85e2f5a1e 100644 --- a/src/beacon/mod.rs +++ b/src/beacon/mod.rs @@ -10,8 +10,6 @@ pub mod metrics; pub mod signatures; pub use beacon_entries::*; pub use drand::*; -#[cfg(test)] -pub use drand_pb::PublicRandResponse; #[cfg(test)] pub mod mock_beacon; diff --git a/src/beacon/signatures/mod.rs b/src/beacon/signatures/mod.rs index f17913f46a3b..c2089b86a4f0 100644 --- a/src/beacon/signatures/mod.rs +++ b/src/beacon/signatures/mod.rs @@ -13,9 +13,15 @@ use rayon::prelude::*; pub use bls_signatures::{PublicKey as PublicKeyOnG1, Signature as SignatureOnG2}; // See -pub(in crate::beacon) const CSUITE_G1: &[u8] = b"BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; +const CSUITE_G1: &[u8] = b"BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; const CSUITE_G2: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_"; +/// Signs `message` on G1 with `secret`, as an unchained `drand` network signs a round. +#[cfg(test)] +pub(in crate::beacon) fn sign_unchained(secret: blstrs::Scalar, message: &[u8]) -> SignatureOnG1 { + SignatureOnG1((G1Projective::hash_to_curve(message, CSUITE_G1, &[]) * secret).to_affine()) +} + #[derive(Debug, Clone, Eq, PartialEq, derive_more::Deref)] pub struct PublicKeyOnG2(pub(crate) G2Projective); diff --git a/src/beacon/tests/drand.rs b/src/beacon/tests/drand.rs index 2f479c27db86..729648cfe74d 100644 --- a/src/beacon/tests/drand.rs +++ b/src/beacon/tests/drand.rs @@ -20,14 +20,18 @@ use std::borrow::Cow; use std::sync::LazyLock; use std::time::Duration; +/// Filecoin mainnet genesis timestamp and epoch duration, which the test beacons map rounds to. +pub const FIL_GENESIS_TIME: u64 = 1598306400; +pub const FIL_BLOCK_DELAY: u64 = 30; + /// Serves recorded `quicknet` responses, so no test below reaches a public relay. static FAKE_DRAND: LazyLock = LazyLock::new(|| FakeDrandServer::start(vec![FakeDrandChain::quicknet()])); fn new_beacon_mainnet() -> DrandBeacon { DrandBeacon::new( - 1598306400, - 30, + FIL_GENESIS_TIME, + FIL_BLOCK_DELAY, &DrandConfig { servers: vec![], // https://api.drand.sh/8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce/info @@ -52,8 +56,8 @@ fn new_beacon_mainnet() -> DrandBeacon { pub fn new_beacon_quicknet() -> DrandBeacon { DrandBeacon::new( - 1598306400, - 30, + FIL_GENESIS_TIME, + FIL_BLOCK_DELAY, &DrandConfig { servers: vec![FAKE_DRAND.url().clone()], // https://api.drand.sh/52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971/info diff --git a/src/beacon/tests/fake_drand_signer.rs b/src/beacon/tests/fake_drand_signer.rs index fdf68c919cb9..e5d623330f11 100644 --- a/src/beacon/tests/fake_drand_signer.rs +++ b/src/beacon/tests/fake_drand_signer.rs @@ -1,19 +1,20 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT +use quick_protobuf::{BytesReader, MessageRead as _, MessageWrite as _, Writer}; + +use super::drand::{FIL_BLOCK_DELAY, FIL_GENESIS_TIME}; +use super::fake_drand_server::FakeDrandChain; use crate::beacon::{ - Beacon, BeaconEntry, ChainInfo, DrandBeacon, DrandConfig, DrandNetwork, PublicRandResponse, + Beacon, BeaconEntry, ChainInfo, DrandBeacon, DrandConfig, DrandNetwork, + drand_pb::PublicRandResponse, signatures::sign_unchained, }; use crate::utils::encoding::hex; -use blstrs::{G1Projective, G2Projective, Scalar}; +use blstrs::{G2Projective, Scalar}; use group::{Curve, Group}; -const FAKE_DRAND_GENESIS_TIME: i32 = 1_692_803_367; -const FAKE_DRAND_PERIOD: i32 = 3; - -const TEST_FIL_GENESIS_TIME: u64 = 1_598_306_400; -const TEST_FIL_BLOCK_DELAY: u64 = 30; - +/// Signs any round of a `quicknet`-like chain with a made-up key, and encodes it as `drand` +/// gossips it. pub struct FakeDrandSigner { secret: Scalar, config: DrandConfig<'static>, @@ -21,17 +22,21 @@ pub struct FakeDrandSigner { impl FakeDrandSigner { pub fn new() -> Self { + // Arbitrary: any non-zero scalar makes a valid key pair. let secret = Scalar::from(0xC0FFEEu64); let public = G2Projective::generator() * secret; let public_key = hex::encode(public.to_affine().to_compressed()); + // Real `quicknet` timing, so rounds map to epochs as on mainnet; the key and hashes + // are made up. + let quicknet = FakeDrandChain::quicknet().info; Self { secret, config: DrandConfig { servers: vec![], chain_info: ChainInfo { public_key: public_key.into(), - period: FAKE_DRAND_PERIOD, - genesis_time: FAKE_DRAND_GENESIS_TIME, + period: quicknet.period, + genesis_time: quicknet.genesis_time, hash: "0011".repeat(16).into(), group_hash: "00".repeat(32).into(), }, @@ -42,37 +47,37 @@ impl FakeDrandSigner { } } - // sign H(round) on G1, exactly what `verify_entries` checks for unchained. pub fn entry(&self, round: u64) -> BeaconEntry { - let msg = BeaconEntry::message_unchained(round); - let point = - G1Projective::hash_to_curve(msg.as_ref(), crate::beacon::signatures::CSUITE_G1, &[]); - let point = point * self.secret; - BeaconEntry::new(round, point.to_affine().to_compressed().to_vec()) + let signature = sign_unchained(self.secret, BeaconEntry::message_unchained(round).as_ref()); + BeaconEntry::new(round, signature.0.to_compressed().to_vec()) } - // encode PublicRandResponse to protobuf - pub fn to_protobuf(&self, round: u64) -> Vec { - self.to_protobuf_raw(round, self.entry(round).signature()) + /// `round`, signed, as a gossiped `PublicRandResponse`. + pub fn encode(&self, round: u64) -> Vec { + self.encode_raw(round, self.entry(round).signature()) } - /// A `PublicRandResponse` payload with any signature. - pub fn to_protobuf_raw(&self, round: u64, signature: &[u8]) -> Vec { + /// A gossiped `PublicRandResponse` with any signature. + pub fn encode_raw(&self, round: u64, signature: &[u8]) -> Vec { let mut out = Vec::new(); - let mut w = quick_protobuf::Writer::new(&mut out); - quick_protobuf::MessageWrite::write_message( - &PublicRandResponse { - round, - signature: signature.to_vec(), - }, - &mut w, - ) + PublicRandResponse { + round, + signature: signature.to_vec(), + } + .write_message(&mut Writer::new(&mut out)) .unwrap(); out } + pub fn decode(payload: &[u8]) -> BeaconEntry { + let response = + PublicRandResponse::from_reader(&mut BytesReader::from_bytes(payload), payload) + .unwrap(); + BeaconEntry::new(response.round, response.signature) + } + pub fn beacon(&self) -> DrandBeacon { - DrandBeacon::new(TEST_FIL_GENESIS_TIME, TEST_FIL_BLOCK_DELAY, &self.config) + DrandBeacon::new(FIL_GENESIS_TIME, FIL_BLOCK_DELAY, &self.config) } } @@ -81,16 +86,7 @@ impl FakeDrandSigner { fn fake_drand_entries_verify() { let d = FakeDrandSigner::new(); let entries: Vec<_> = (1..=5) - .map(|r| { - let payload = d.to_protobuf(r); - let mut reader = quick_protobuf::BytesReader::from_bytes(&payload); - let response = ::from_reader( - &mut reader, - &payload, - ) - .unwrap(); - BeaconEntry::new(response.round, response.signature) - }) + .map(|round| FakeDrandSigner::decode(&d.encode(round))) .collect(); assert!( d.beacon()