From 91cb0028173a049a566a459ac83af5eceae57a0f Mon Sep 17 00:00:00 2001 From: Animesh Roy Date: Sun, 9 Aug 2026 19:19:03 +0530 Subject: [PATCH] test(types): pin Vote/Proposal sign-bytes length disjointness Vote::to_sign_bytes and Proposal::to_sign_bytes hand raw SSZ to the signer with no domain separator, unlike ValidatorProof::signing_bytes which prefixes b"PoV". The two cannot currently collide because their encoded lengths are disjoint (Vote [43, 75] vs Proposal [74, 78]) -- Vote has one variable field pair and Proposal has two, so their SSZ offset tables differ in size. That disjointness is an emergent property of the current field sets rather than an enforced one. Adding a fixed field to Vote, or making Value variable-length, could make the sets overlap and silently allow a signature over a vote to be replayed as a proposal. This test pins the invariant so such a change fails loudly, and names the fix (an explicit domain separator) in the assertion message. --- crates/types/src/proposal.rs | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/types/src/proposal.rs b/crates/types/src/proposal.rs index 8e8a7882..1706c276 100644 --- a/crates/types/src/proposal.rs +++ b/crates/types/src/proposal.rs @@ -169,4 +169,65 @@ mod tests { assert!(!::is_ssz_fixed_len()); } + + /// Signed preimages of different consensus message types must never collide. + /// + /// `Vote::to_sign_bytes` and `Proposal::to_sign_bytes` both hand raw SSZ to the + /// signer with no domain separator, unlike `ValidatorProof::signing_bytes`, which + /// prefixes `b"PoV"`. Today the two cannot collide because their encoded lengths are + /// disjoint -- `Vote` has one variable field pair and `Proposal` has two, so their SSZ + /// offset tables differ in size. That is an emergent property of the current field + /// sets, not an enforced one: adding a fixed field to `Vote`, or making `Value` + /// variable-length, could make the sets overlap and silently permit a signature over a + /// vote to be replayed as a proposal. + /// + /// This test pins the invariant so such a change fails loudly instead. + #[test] + fn test_vote_and_proposal_sign_bytes_lengths_are_disjoint() { + use alloy_primitives::BlockHash; + + use crate::{Vote, ValueId}; + use malachitebft_core_types::NilOrVal; + + let height = Height::new(100); + let round = Round::new(5); + let block_hash = BlockHash::new([0xAA; 32]); + let validator_address = Address::new([0xBB; 20]); + + // `to_sign_bytes` asserts the round is defined, so only `pol_round` / `value` vary. + let vote_lens: Vec = [NilOrVal::Nil, NilOrVal::Val(ValueId::new(block_hash))] + .into_iter() + .map(|value| { + Vote::new_prevote(height, round, value, validator_address) + .to_sign_bytes() + .len() + }) + .collect(); + + let proposal_lens: Vec = [Round::Nil, Round::new(3)] + .into_iter() + .map(|pol_round| { + Proposal::new( + height, + round, + Value::new(block_hash), + pol_round, + validator_address, + ) + .to_sign_bytes() + .len() + }) + .collect(); + + for v in &vote_lens { + assert!( + !proposal_lens.contains(v), + "Vote and Proposal signed-byte lengths overlap at {v} bytes \ + (vote={vote_lens:?}, proposal={proposal_lens:?}). Cross-type signature \ + replay may be possible; add an explicit domain separator to \ + `to_sign_bytes` for both types." + ); + } + } + }