diff --git a/Cargo.lock b/Cargo.lock index 9c7ba2a3..fe0c9cfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5589,7 +5589,6 @@ version = "1.7.1" dependencies = [ "alloy", "anyhow", - "async-trait", "base64", "built", "cancellation", @@ -5608,6 +5607,7 @@ dependencies = [ "pluto-eth2api", "pluto-eth2util", "pluto-featureset", + "pluto-k1util", "pluto-p2p", "pluto-ssz", "pluto-testutil", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e4133302..2aa595a4 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -7,17 +7,18 @@ license.workspace = true publish.workspace = true [dependencies] -async-trait.workspace = true cancellation.workspace = true chrono.workspace = true crossbeam.workspace = true dyn-clone.workspace = true dyn-eq.workspace = true hex.workspace = true +k256.workspace = true libp2p.workspace = true vise.workspace = true pluto-crypto.workspace = true pluto-eth2api.workspace = true +pluto-k1util.workspace = true prost.workspace = true prost-types.workspace = true regex.workspace = true @@ -40,7 +41,6 @@ alloy.workspace = true clap.workspace = true rand.workspace = true libp2p.workspace = true -k256.workspace = true prost.workspace = true prost-types.workspace = true hex.workspace = true diff --git a/crates/core/src/consensus/mod.rs b/crates/core/src/consensus/mod.rs index 35e33056..75b5a06e 100644 --- a/crates/core/src/consensus/mod.rs +++ b/crates/core/src/consensus/mod.rs @@ -9,6 +9,8 @@ pub mod protocols; /// Consensus instance I/O channels. pub mod instance; +/// QBFT consensus wrapper. +pub mod qbft; /// Consensus round timers. pub mod timer; diff --git a/crates/core/src/consensus/qbft/mod.rs b/crates/core/src/consensus/qbft/mod.rs new file mode 100644 index 00000000..a18db6be --- /dev/null +++ b/crates/core/src/consensus/qbft/mod.rs @@ -0,0 +1,4 @@ +//! QBFT consensus wrapper. + +/// QBFT protobuf message wrapper. +pub mod msg; diff --git a/crates/core/src/consensus/qbft/msg.rs b/crates/core/src/consensus/qbft/msg.rs new file mode 100644 index 00000000..f2339a9a --- /dev/null +++ b/crates/core/src/consensus/qbft/msg.rs @@ -0,0 +1,760 @@ +//! QBFT protobuf message adapter. +//! +//! This module bridges the domain-specific consensus protobuf messages with +//! the generic [`crate::qbft`] state machine. +//! +//! [`QbftMsg`](pbconsensus::QbftMsg) carries only consensus metadata and value +//! hashes. The concrete proposal values are transported beside it in +//! [`QbftConsensusMsg`](pbconsensus::QbftConsensusMsg) as protobuf `Any` +//! payloads. [`Msg`] ties those two pieces back together by: +//! +//! - converting `value_hash` and `prepared_value_hash` into fixed `[u8; 32]` +//! values for the generic QBFT core; +//! - checking that every non-zero hash referenced by the message exists in the +//! supplied [`ValueMap`]; +//! - recursively wrapping raw justification messages so the core can validate +//! PRE-PREPARE and ROUND-CHANGE justifications; +//! - preserving the raw protobufs so the transport layer can rebuild the +//! original consensus message with [`Msg::to_consensus_msg`]. +//! +//! Do not hash `Any` directly. The consensus hash is over the deterministic +//! protobuf bytes of the inner message. +//! +//! Inbound callers validate message type, duty type, peer membership, rounds, +//! and signatures before constructing [`Msg`]. This adapter preserves raw +//! message types, while invalid duty wire values project to +//! [`DutyType::Unknown`]. + +// TODO: Remove once component/transport wiring uses the crate-visible helpers. +#![allow(dead_code)] + +use std::{any, collections::HashMap, fmt, sync}; + +use k256::{PublicKey, SecretKey}; +use pluto_ssz::{HashWalker, Hasher, HasherError}; +use prost_types::Any; + +use crate::{ + corepb::v1::{consensus as pbconsensus, core as pbcore}, + qbft::{self, MessageType, SomeMsg}, + types::{Duty, DutyType, SlotNumber}, +}; + +/// Type mapping used by the consensus adapter when invoking generic QBFT. +/// +/// - Instance: [`Duty`] +/// - Value: `[u8; 32]` hash of the concrete proposal value +/// - Compare: original `Any` payload passed to the application compare callback +pub struct ConsensusQbftTypes; + +impl qbft::QbftTypes for ConsensusQbftTypes { + type Compare = Any; + type Instance = Duty; + type Value = [u8; 32]; +} + +/// Concrete values carried beside QBFT hash messages. +/// +/// The key is the [`hash_proto`] result of the decoded inner protobuf message. +/// The value remains the original `Any` envelope so later layers can forward or +/// compare the same payload without losing type-url information. +pub type ValueMap = HashMap<[u8; 32], Any>; + +type Result = std::result::Result; + +/// Errors returned by QBFT message wrapping. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Nil QBFT protobuf message. + #[error("nil qbft message")] + NilQbftMessage, + + /// Value hash did not exist in the values map. + #[error("value hash not found in values")] + ValueHashNotFound, + + /// Prepared value hash did not exist in the values map. + #[error("prepared value hash not found in values")] + PreparedValueHashNotFound, + + /// Value did not exist in the values map. + #[error("value not found")] + ValueNotFound, + + /// Callers must hash the concrete inner message, not `Any`. + #[error("cannot hash any proto, must hash inner value")] + CannotHashAnyProto, + + /// Protobuf marshal failed. + #[error("marshal proto: {0}")] + MarshalProto(#[source] prost::EncodeError), + + /// SSZ hash failed. + #[error("hash proto: {0}")] + HashProto(#[source] HasherError), + + /// QBFT message signature was empty. + #[error("empty signature")] + EmptySignature, + + /// Public key recovery failed. + #[error("recover pubkey: {0}")] + RecoverPubkey(#[source] pluto_k1util::K1UtilError), + + /// Signing failed. + #[error("sign: {0}")] + Sign(#[source] pluto_k1util::K1UtilError), +} + +/// Wrapped consensus message consumed by the generic QBFT core. +/// +/// The raw protobuf remains available for re-broadcasting. The hash fields are +/// cached as `[u8; 32]` because the core treats consensus values as comparable +/// hashes, not full protobuf payloads. +#[derive(Clone)] +pub struct Msg { + msg: pbconsensus::QbftMsg, + value_hash: [u8; 32], + prepared_value_hash: [u8; 32], + values: sync::Arc, + justification_protos: Vec, + justification: Vec>, +} + +impl fmt::Debug for Msg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Msg") + .field("type", &MessageType::from_wire(self.msg.r#type).to_string()) + .field( + "duty", + &self.msg.duty.as_ref().map(|duty| (duty.slot, duty.r#type)), + ) + .field("peer_idx", &self.msg.peer_idx) + .field("round", &self.msg.round) + .field("prepared_round", &self.msg.prepared_round) + .field("value_hash", &self.value_hash) + .field("prepared_value_hash", &self.prepared_value_hash) + .field("values_len", &self.values.len()) + .field("justification_len", &self.justification.len()) + .finish() + } +} + +impl Msg { + /// Wraps a raw QBFT protobuf message for the generic core. + /// + /// Non-zero `value_hash` and `prepared_value_hash` fields must both exist + /// in `values`. Invalid hash encodings, including zero hashes, are + /// treated as the nil value and do not require a map entry. + /// + /// Justifications are raw protobuf messages from the same consensus + /// envelope. They are recursively wrapped with the same shared value map. + pub(crate) fn new( + msg: Option, + justification: Vec, + values: sync::Arc, + ) -> Result { + let msg = msg.ok_or(Error::NilQbftMessage)?; + + let value_hash = match to_hash32(&msg.value_hash) { + Some(hash) if values.contains_key(&hash) => hash, + Some(_) => return Err(Error::ValueHashNotFound), + None => [0u8; 32], + }; + let prepared_value_hash = match to_hash32(&msg.prepared_value_hash) { + Some(hash) if values.contains_key(&hash) => hash, + Some(_) => return Err(Error::PreparedValueHashNotFound), + None => [0u8; 32], + }; + + let mut justification_impls: Vec> = + Vec::with_capacity(justification.len()); + + for justification_msg in &justification { + let impl_msg = Self::new(Some(justification_msg.clone()), vec![], values.clone())?; + justification_impls.push(sync::Arc::new(impl_msg)); + } + + Ok(Self { + msg, + value_hash, + prepared_value_hash, + values, + justification_protos: justification, + justification: justification_impls, + }) + } + + /// Returns the raw protobuf message. + pub fn msg(&self) -> &pbconsensus::QbftMsg { + &self.msg + } + + /// Returns the values map shared by this message and nested justifications. + pub fn values(&self) -> &ValueMap { + &self.values + } + + /// Returns the `Any` payload for this message's `value_hash`. + pub fn value_source(&self) -> Result { + self.values + .get(&self.value_hash) + .cloned() + .ok_or(Error::ValueNotFound) + } + + /// Rebuilds the protobuf consensus envelope for transport. + pub fn to_consensus_msg(&self) -> pbconsensus::QbftConsensusMsg { + pbconsensus::QbftConsensusMsg { + msg: Some(self.msg.clone()), + justification: self.justification_protos.clone(), + values: self.values.values().cloned().collect(), + } + } +} + +impl SomeMsg for Msg { + fn type_(&self) -> MessageType { + MessageType::from_wire(self.msg.r#type) + } + + fn instance(&self) -> Duty { + duty_from_proto(self.msg.duty.as_ref()) + } + + fn source(&self) -> i64 { + self.msg.peer_idx + } + + fn round(&self) -> i64 { + self.msg.round + } + + fn value(&self) -> [u8; 32] { + self.value_hash + } + + fn value_source(&self) -> std::result::Result { + self.values + .get(&self.value_hash) + .cloned() + .ok_or(qbft::QbftError::ValueNotFound) + } + + fn prepared_round(&self) -> i64 { + self.msg.prepared_round + } + + fn prepared_value(&self) -> [u8; 32] { + self.prepared_value_hash + } + + fn justification(&self) -> Vec> { + self.justification.clone() + } + + fn as_any(&self) -> &dyn any::Any { + self + } +} + +/// Returns a deterministic SSZ hash root of a concrete protobuf message. +/// +/// The hash input is deterministic protobuf encoding, then SSZ `PutBytes` +/// merkleization. `Any` is rejected because the consensus value hash must bind +/// to the inner message bytes, not the transport envelope. +pub(crate) fn hash_proto(msg: &M) -> Result<[u8; 32]> +where + M: prost::Message + prost::Name, +{ + if M::PACKAGE == "google.protobuf" && M::NAME == "Any" { + return Err(Error::CannotHashAnyProto); + } + + let mut encoded = Vec::with_capacity(msg.encoded_len()); + msg.encode(&mut encoded).map_err(Error::MarshalProto)?; + + let mut hasher = Hasher::default(); + let index = hasher.index(); + hasher.put_bytes(&encoded).map_err(Error::HashProto)?; + hasher.merkleize(index).map_err(Error::HashProto)?; + hasher.hash_root().map_err(Error::HashProto) +} + +/// Returns a signed copy of a QBFT protobuf message. +/// +/// The signature field is cleared before hashing, so callers may pass either an +/// unsigned message or an already-signed message to re-sign. +pub(crate) fn sign_msg( + msg: &pbconsensus::QbftMsg, + privkey: &SecretKey, +) -> Result { + let mut clone = msg.clone(); + clone.signature.clear(); + + let hash = hash_proto(&clone)?; + let signature = pluto_k1util::sign(privkey, &hash).map_err(Error::Sign)?; + clone.signature = signature.to_vec().into(); + + Ok(clone) +} + +/// Verifies that a QBFT protobuf message was signed by `pubkey`. +/// +/// The signature is recoverable secp256k1 over [`hash_proto`] of the message +/// with its signature field cleared. +pub(crate) fn verify_msg_sig(msg: &pbconsensus::QbftMsg, pubkey: &PublicKey) -> Result { + // Protobuf `bytes` fields decode both absent and explicit-empty values as + // empty bytes in prost. + if msg.signature.is_empty() { + return Err(Error::EmptySignature); + } + + let mut clone = msg.clone(); + clone.signature.clear(); + + let hash = hash_proto(&clone)?; + let recovered = pluto_k1util::recover(&hash, &msg.signature).map_err(Error::RecoverPubkey)?; + + Ok(recovered == *pubkey) +} + +fn to_hash32(value: &[u8]) -> Option<[u8; 32]> { + let value: [u8; 32] = value.try_into().ok()?; + if value == [0u8; 32] { + return None; + } + + Some(value) +} + +fn duty_from_proto(duty: Option<&pbcore::Duty>) -> Duty { + let Some(duty) = duty else { + return Duty::new(SlotNumber::new(0), DutyType::Unknown); + }; + + // Message receive validation rejects invalid duty types before this adapter + // is used by the consensus runner. If an invalid value reaches this local + // projection, Rust's closed enum maps it to Unknown instead of preserving + // the raw wire value. + let duty_type: DutyType = DutyType::try_from(duty.r#type).unwrap_or(DutyType::Unknown); + Duty::new(SlotNumber::new(duty.slot), duty_type) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::qbft::{MSG_PRE_PREPARE, MSG_PREPARE}; + use prost::bytes::Bytes; + use prost_types::Timestamp; + use test_case::test_case; + + const UNSIGNED_DATASET_HASH: &str = + "d8f9bc3de8b0cb0e3eb1f773c14a96d58f7acaf0f09192ce6562d84ea315e67b"; + const UNSIGNED_DATASET_KEY: &str = "0xe5301bb68d031b01ef7f35613a77f05f6134983fedd8b0107ec2e45c9bb480eb52accb3174a9a936f255f96410d2eb03"; + const UNSIGNED_DATASET_VALUE_HEX: &str = "0800000088000000394651850fd4010078892ee285ec0100511455780875d64ee2d3d0d0de6bf8f9b44ce85ff044c6b1f83b8e883bbf857ac354f3ede2d61e0067cfe242cf3ccc4ea3ae5e88526a9f4a578bcb9ef2d4a65314768d6d299761ea045c3f000f8a1900ddcdd01d756bce6c512c3801aacaeedfad5b506664e8c0e4a771ece0b8b7c196a5512e043e9b9aa687907adf5dba61350991daef80dd5c470c90650aaf7b5fd90022215ae7966bb600191b1825f88d4273c86e4ff95f160062a5eee82abd14004a2d0b75fb180d0000010000000000000001000000000000e000000000000000"; + const TIMESTAMP_HASH: &str = "0880e2cfaa0610959aef3a000000000000000000000000000000000000000000"; + const QBFT_MSG_HASH: &str = "9423898db5f4fc224e07cd775a03d7dc89dafe6aedfda9f75cccb1f17c3ba803"; + const SIGNING_PRIVKEY: &str = + "41d3ff12045b73c870529fe44f70dca2745bafbe1698ffc3c8759eef3cfbaee1"; + const WRONG_PRIVKEY: &str = "42d3ff12045b73c870529fe44f70dca2745bafbe1698ffc3c8759eef3cfbaee1"; + const QBFT_MSG_SIGNATURE: &str = "8a3d48258325037ce680c0bfd40ebc95ff53865b9a7ea391308f27dd1be324791647d3814dc40e9c1edbf6b50e62b99dbc7401724c975ffc0673d034fb9bb0df01"; + + #[test_case(vec![] ; "empty")] + #[test_case(vec![1; 31] ; "short")] + #[test_case(vec![1; 33] ; "long")] + #[test_case(vec![0; 32] ; "zero_hash")] + fn to_hash32_rejects_invalid_hashes(value: Vec) { + assert_eq!(to_hash32(&value), None); + } + + #[test] + fn to_hash32_accepts_nonzero_32_bytes() { + assert_eq!(to_hash32(&[1u8; 32]), Some([1u8; 32])); + } + + #[test] + fn hash_proto_matches_seeded_unsigned_dataset() { + let mut set = std::collections::BTreeMap::new(); + set.insert( + UNSIGNED_DATASET_KEY.to_string(), + Bytes::from(hex::decode(UNSIGNED_DATASET_VALUE_HEX).unwrap()), + ); + + let hash = hash_proto(&pbcore::UnsignedDataSet { set }).unwrap(); + + assert_eq!(hex::encode(hash), UNSIGNED_DATASET_HASH); + } + + #[test] + fn hash_proto_matches_timestamp() { + let hash = hash_proto(&Timestamp { + seconds: 1_700_000_000, + nanos: 123_456_789, + }) + .unwrap(); + + assert_eq!(hex::encode(hash), TIMESTAMP_HASH); + } + + #[test] + fn hash_proto_matches_qbft_msg() { + let hash = hash_proto(&fixed_qbft_msg()).unwrap(); + + assert_eq!(hex::encode(hash), QBFT_MSG_HASH); + } + + #[test] + fn hash_proto_uses_btree_map_for_deterministic_encoding() { + let mut forward = std::collections::BTreeMap::new(); + forward.insert("a".to_string(), Bytes::from_static(b"first")); + forward.insert("b".to_string(), Bytes::from_static(b"second")); + + let mut reverse = std::collections::BTreeMap::new(); + reverse.insert("b".to_string(), Bytes::from_static(b"second")); + reverse.insert("a".to_string(), Bytes::from_static(b"first")); + + assert_eq!( + hash_proto(&pbcore::UnsignedDataSet { set: forward }).unwrap(), + hash_proto(&pbcore::UnsignedDataSet { set: reverse }).unwrap() + ); + } + + #[test] + fn hash_proto_rejects_any() { + let any = Any::from_msg(&Timestamp { + seconds: 1, + nanos: 2, + }) + .unwrap(); + + let err = hash_proto(&any).unwrap_err(); + + assert_eq!( + err.to_string(), + "cannot hash any proto, must hash inner value" + ); + } + + #[test] + fn new_rejects_nil_message() { + let err = Msg::new(None, vec![], sync::Arc::default()).unwrap_err(); + + assert_eq!(err.to_string(), "nil qbft message"); + } + + #[test] + fn debug_unknown_message_type() { + let msg = Msg::new( + Some(pbconsensus::QbftMsg { + r#type: 99, + ..Default::default() + }), + vec![], + sync::Arc::default(), + ) + .unwrap(); + + let debug = format!("{msg:?}"); + + assert!(debug.contains("type: \"\"")); + } + + #[test] + fn new_maps_valid_value_and_prepared_hashes() { + let value_hash = hash_proto(×tamp(1)).unwrap(); + let prepared_hash = hash_proto(×tamp(2)).unwrap(); + let values = sync::Arc::new(value_map(vec![ + (value_hash, any_timestamp(1)), + (prepared_hash, any_timestamp(2)), + ])); + + let msg = Msg::new( + Some(pbconsensus::QbftMsg { + r#type: 1, + duty: Some(pbcore::Duty { + slot: 42, + r#type: 2, + }), + peer_idx: 7, + round: 3, + prepared_round: 2, + value_hash: value_hash.to_vec().into(), + prepared_value_hash: prepared_hash.to_vec().into(), + ..Default::default() + }), + vec![], + values, + ) + .unwrap(); + + assert_eq!(msg.type_(), MSG_PRE_PREPARE); + assert_eq!( + msg.instance(), + Duty::new(SlotNumber::new(42), DutyType::Attester) + ); + assert_eq!(msg.source(), 7); + assert_eq!(msg.round(), 3); + assert_eq!(msg.value(), value_hash); + assert_eq!(msg.prepared_round(), 2); + assert_eq!(msg.prepared_value(), prepared_hash); + assert_eq!(msg.value_source().unwrap(), any_timestamp(1)); + assert_eq!(msg.values().len(), 2); + } + + #[test_case(vec![1; 31] ; "invalid_length")] + #[test_case(vec![0; 32] ; "zero_hash")] + fn new_treats_invalid_value_hash_as_nil(hash: Vec) { + let msg = Msg::new( + Some(pbconsensus::QbftMsg { + value_hash: hash.into(), + ..Default::default() + }), + vec![], + sync::Arc::default(), + ) + .unwrap(); + + assert_eq!(msg.value(), [0u8; 32]); + } + + #[test_case(vec![1; 31] ; "invalid_length")] + #[test_case(vec![0; 32] ; "zero_hash")] + fn new_treats_invalid_prepared_value_hash_as_nil(hash: Vec) { + let msg = Msg::new( + Some(pbconsensus::QbftMsg { + prepared_value_hash: hash.into(), + ..Default::default() + }), + vec![], + sync::Arc::default(), + ) + .unwrap(); + + assert_eq!(msg.prepared_value(), [0u8; 32]); + } + + #[test] + fn new_errors_on_missing_value_hash() { + let err = Msg::new( + Some(pbconsensus::QbftMsg { + value_hash: [1u8; 32].to_vec().into(), + ..Default::default() + }), + vec![], + sync::Arc::default(), + ) + .unwrap_err(); + + assert_eq!(err.to_string(), "value hash not found in values"); + } + + #[test] + fn new_errors_on_missing_prepared_value_hash() { + let err = Msg::new( + Some(pbconsensus::QbftMsg { + prepared_value_hash: [2u8; 32].to_vec().into(), + ..Default::default() + }), + vec![], + sync::Arc::default(), + ) + .unwrap_err(); + + assert_eq!(err.to_string(), "prepared value hash not found in values"); + } + + #[test] + fn new_errors_on_nested_justification_missing_value() { + let err = Msg::new( + Some(pbconsensus::QbftMsg::default()), + vec![pbconsensus::QbftMsg { + value_hash: [3u8; 32].to_vec().into(), + ..Default::default() + }], + sync::Arc::default(), + ) + .unwrap_err(); + + assert_eq!(err.to_string(), "value hash not found in values"); + } + + #[test] + fn value_source_errors_when_value_missing() { + let msg = Msg::new( + Some(pbconsensus::QbftMsg::default()), + vec![], + sync::Arc::default(), + ) + .unwrap(); + + let err = msg.value_source().unwrap_err(); + + assert_eq!(err.to_string(), "value not found"); + } + + #[test] + fn new_maps_justification() { + let value_hash = hash_proto(×tamp(1)).unwrap(); + let values = sync::Arc::new(value_map(vec![(value_hash, any_timestamp(1))])); + + let msg = Msg::new( + Some(pbconsensus::QbftMsg::default()), + vec![pbconsensus::QbftMsg { + r#type: 2, + value_hash: value_hash.to_vec().into(), + ..Default::default() + }], + values, + ) + .unwrap(); + + let justification = msg.justification(); + + assert_eq!(justification.len(), 1); + assert_eq!(justification[0].type_(), MSG_PREPARE); + assert_eq!(justification[0].value(), value_hash); + } + + #[test] + fn to_consensus_msg_preserves_raw_message_justification_and_values() { + let value_hash = hash_proto(×tamp(1)).unwrap(); + let prepared_hash = hash_proto(×tamp(2)).unwrap(); + let value_1 = any_timestamp(1); + let value_2 = any_timestamp(2); + let values = sync::Arc::new(value_map(vec![ + (value_hash, value_1.clone()), + (prepared_hash, value_2.clone()), + ])); + let raw_msg = pbconsensus::QbftMsg { + r#type: 1, + value_hash: value_hash.to_vec().into(), + ..Default::default() + }; + let raw_justification = pbconsensus::QbftMsg { + r#type: 2, + prepared_value_hash: prepared_hash.to_vec().into(), + ..Default::default() + }; + + let msg = Msg::new( + Some(raw_msg.clone()), + vec![raw_justification.clone()], + values, + ) + .unwrap(); + let consensus_msg = msg.to_consensus_msg(); + + assert_eq!(msg.msg(), &raw_msg); + assert_eq!(consensus_msg.msg, Some(raw_msg)); + assert_eq!(consensus_msg.justification, vec![raw_justification]); + assert_eq!(consensus_msg.values.len(), 2); + assert_eq!( + sorted_any(consensus_msg.values), + sorted_any(vec![value_1, value_2]) + ); + } + + #[test] + fn sign_msg_matches_expected_signature_and_verifies() { + let key = secret_key(SIGNING_PRIVKEY); + + let signed = sign_msg(&fixed_qbft_msg(), &key).unwrap(); + + assert_eq!(hex::encode(&signed.signature), QBFT_MSG_SIGNATURE); + assert!(verify_msg_sig(&signed, &key.public_key()).unwrap()); + } + + #[test] + fn sign_msg_resigns_already_signed_message() { + let key = secret_key(SIGNING_PRIVKEY); + let signed = sign_msg(&fixed_qbft_msg(), &key).unwrap(); + + let resigned = sign_msg(&signed, &key).unwrap(); + + assert_eq!(resigned, signed); + } + + #[test] + fn verify_msg_sig_wrong_key_returns_false() { + let key = secret_key(SIGNING_PRIVKEY); + let wrong_key = secret_key(WRONG_PRIVKEY); + let signed = sign_msg(&fixed_qbft_msg(), &key).unwrap(); + + let ok = verify_msg_sig(&signed, &wrong_key.public_key()).unwrap(); + + assert!(!ok); + } + + #[test] + fn verify_msg_sig_tampered_message_returns_false() { + let key = secret_key(SIGNING_PRIVKEY); + let mut signed = sign_msg(&fixed_qbft_msg(), &key).unwrap(); + signed.round += 1; + + let ok = verify_msg_sig(&signed, &key.public_key()).unwrap(); + + assert!(!ok); + } + + #[test] + fn verify_msg_sig_errors_on_empty_signature() { + let err = verify_msg_sig(&fixed_qbft_msg(), &secret_key(SIGNING_PRIVKEY).public_key()) + .unwrap_err(); + + assert_eq!(err.to_string(), "empty signature"); + } + + #[test] + fn verify_msg_sig_errors_on_malformed_signature() { + let key = secret_key(SIGNING_PRIVKEY); + let mut msg = fixed_qbft_msg(); + msg.signature = vec![0x42u8; 64].into(); + + let err = verify_msg_sig(&msg, &key.public_key()).unwrap_err(); + + assert!(matches!(err, Error::RecoverPubkey(_))); + assert!(std::error::Error::source(&err).is_some()); + } + + fn timestamp(seconds: i64) -> Timestamp { + Timestamp { seconds, nanos: 0 } + } + + fn any_timestamp(seconds: i64) -> Any { + Any::from_msg(×tamp(seconds)).unwrap() + } + + fn value_map(values: Vec<([u8; 32], Any)>) -> ValueMap { + values.into_iter().collect() + } + + fn sorted_any(values: Vec) -> Vec<(String, Vec)> { + let mut values = values + .into_iter() + .map(|value| (value.type_url, value.value.to_vec())) + .collect::>(); + values.sort(); + values + } + + fn secret_key(hex_key: &str) -> SecretKey { + SecretKey::from_slice(&hex::decode(hex_key).unwrap()).unwrap() + } + + fn fixed_qbft_msg() -> pbconsensus::QbftMsg { + pbconsensus::QbftMsg { + r#type: 1, + duty: Some(pbcore::Duty { + slot: 42, + r#type: 2, + }), + peer_idx: 7, + round: 3, + prepared_round: 2, + value_hash: [0x11u8; 32].to_vec().into(), + prepared_value_hash: [0x22u8; 32].to_vec().into(), + ..Default::default() + } + } +} diff --git a/crates/core/src/qbft/mod.rs b/crates/core/src/qbft/mod.rs index 554fcfb6..00b876ad 100644 --- a/crates/core/src/qbft/mod.rs +++ b/crates/core/src/qbft/mod.rs @@ -75,6 +75,10 @@ pub enum QbftError { #[error("Zero input value not supported")] ZeroInputValue, + /// Message value source was missing. + #[error("value not found")] + ValueNotFound, + /// Node count must be positive. #[error("invalid node count: must be greater than zero, got {nodes}")] InvalidNodes { @@ -200,6 +204,11 @@ pub const MSG_DECIDED: MessageType = MessageType(5); const MSG_SENTINEL: MessageType = MessageType(6); // intentionally not public impl MessageType { + /// Converts a stable wire integer into a message type without clamping. + pub fn from_wire(value: i64) -> Self { + Self(value) + } + /// Returns true when the message type is one of the known QBFT wire types. pub fn valid(&self) -> bool { self.0 > MSG_UNKNOWN.0 && self.0 < MSG_SENTINEL.0 @@ -216,9 +225,9 @@ impl Display for MessageType { 3 => "commit", 4 => "round_change", 5 => "decided", - _ => panic!("bug: invalid message type"), + _ => "", }; - write!(f, "{}", s) + write!(f, "{s}") } } @@ -286,9 +295,9 @@ impl Display for UponRule { 6 => "quorum_round_changes", 7 => "justified_decided", 8 => "round_timeout", - _ => panic!("bug: invalid upon rule"), + _ => "", }; - write!(f, "{}", s) + write!(f, "{s}") } } @@ -1377,6 +1386,35 @@ fn uniq_source() -> impl FnMut(&Msg) -> bool { move |msg: &Msg| sources.insert(msg.source()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_type_from_wire_preserves_known_types() { + assert_eq!(MessageType::from_wire(0), MSG_UNKNOWN); + assert_eq!(MessageType::from_wire(1), MSG_PRE_PREPARE); + assert_eq!(MessageType::from_wire(2), MSG_PREPARE); + assert_eq!(MessageType::from_wire(3), MSG_COMMIT); + assert_eq!(MessageType::from_wire(4), MSG_ROUND_CHANGE); + assert_eq!(MessageType::from_wire(5), MSG_DECIDED); + } + + #[test] + fn message_type_from_wire_preserves_unknown_wire_value() { + let message_type = MessageType::from_wire(99); + + assert_eq!(message_type, MessageType(99)); + assert!(!message_type.valid()); + assert_eq!(message_type.to_string(), ""); + } + + #[test] + fn upon_rule_display_unknown_value_does_not_panic() { + assert_eq!(UponRule(99).to_string(), ""); + } +} + #[cfg(test)] mod fake_clock; #[cfg(test)]