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..e51f379b4fbe --- /dev/null +++ b/proto/drand_pb.proto @@ -0,0 +1,13 @@ +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/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..30d85e2f5a1e 100644 --- a/src/beacon/mod.rs +++ b/src/beacon/mod.rs @@ -3,6 +3,9 @@ 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::*; @@ -16,4 +19,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..c2089b86a4f0 100644 --- a/src/beacon/signatures/mod.rs +++ b/src/beacon/signatures/mod.rs @@ -16,6 +16,12 @@ pub use bls_signatures::{PublicKey as PublicKeyOnG1, Signature as SignatureOnG2} 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 new file mode 100644 index 000000000000..e5d623330f11 --- /dev/null +++ b/src/beacon/tests/fake_drand_signer.rs @@ -0,0 +1,96 @@ +// 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, + drand_pb::PublicRandResponse, signatures::sign_unchained, +}; +use crate::utils::encoding::hex; +use blstrs::{G2Projective, Scalar}; +use group::{Curve, Group}; + +/// 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>, +} + +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: quicknet.period, + genesis_time: quicknet.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, + }, + } + } + + pub fn entry(&self, round: u64) -> BeaconEntry { + let signature = sign_unchained(self.secret, BeaconEntry::message_unchained(round).as_ref()); + BeaconEntry::new(round, signature.0.to_compressed().to_vec()) + } + + /// `round`, signed, as a gossiped `PublicRandResponse`. + pub fn encode(&self, round: u64) -> Vec { + self.encode_raw(round, self.entry(round).signature()) + } + + /// A gossiped `PublicRandResponse` with any signature. + pub fn encode_raw(&self, round: u64, signature: &[u8]) -> Vec { + let mut out = Vec::new(); + 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(FIL_GENESIS_TIME, 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(|round| FakeDrandSigner::decode(&d.encode(round))) + .collect(); + assert!( + d.beacon() + .verify_entries(&entries, &BeaconEntry::default()) + .unwrap() + ); +}