test(types): pin Vote/Proposal sign-bytes length disjointness - #246
test(types): pin Vote/Proposal sign-bytes length disjointness#246anir0y wants to merge 1 commit into
Conversation
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.
There was a problem hiding this comment.
Pull request overview
Adds a regression test to make explicit (and enforce via CI) an implicit consensus safety invariant: Vote::to_sign_bytes() and Proposal::to_sign_bytes() currently produce SSZ payloads whose lengths are disjoint, preventing cross-type signature replay despite lacking a domain separator.
Changes:
- Adds a unit test asserting
VoteandProposalsigned-byte length sets do not intersect. - Documents the rationale and expected mitigation (domain separation) if the invariant ever breaks.
Suppressed comments (1)
crates/types/src/proposal.rs:202
- This test is meant to guard against cross-type replay for any
Vote, butvote_lensonly covers thePrevotevariant. Including bothnew_prevoteandnew_precommitmakes the invariant explicit for all votes (and will catch any future change whereVoteTypeencoding affects SSZ length).
let vote_lens: Vec<usize> = [NilOrVal::Nil, NilOrVal::Val(ValueId::new(block_hash))]
.into_iter()
.map(|value| {
Vote::new_prevote(height, round, value, validator_address)
.to_sign_bytes()
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// `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 |
|
Verified this against current The premise is accurate. The length sets check out arithmetically from the struct definitions:
Coverage of the variable dimensions is complete, which matters for a pinning test: The "test now, separator later" split is correct. Changing the preimage is consensus-breaking (every validator/follower must flip together or signature verification forks), so a drive-by PR is the wrong vehicle for the real fix — and this PR says so instead of pretending otherwise. Also checked for duplication: no existing test in One optional extension, take or leave: the same signing key domain has a third preimage family — Disclosure: I reviewed statically (struct-level length derivation above); I didn't execute the test in a full workspace build, so CI is the authority on the run itself. |
|
Confirming Copilot's inline comment on the doc wording — it's right, and worth fixing because the test is sound but the explanation it pins into the codebase is not. Counting variable-length fields per container (a field is variable iff it encodes behind a 4-byte offset):
So the offset tables are the same size (two 4-byte offsets each), contrary to the doc comment's "Vote has one variable field pair and Proposal has two, so their SSZ offset tables differ in size." The 31-byte fixed-part gap comes from two things the doc doesn't name: Why the wording matters more than usual here: this doc comment is the failure-time explanation a future maintainer will read when the assertion fires. If it tells them to look at offset-table sizes, they'll be debugging the wrong mechanism. Suggested replacement for the relevant sentence:
(Same fix applies to the PR description's table paragraph.) On Copilot's suppressed suggestion to also sample Neither point changes the verdict: assertion logic, length sets, and the non-breaking framing all still hold — this is a doc-accuracy fix plus an optional one-line hardening. |
Summary
Adds a regression test pinning an invariant that consensus signing currently relies on implicitly.
This is a hardening/robustness contribution, not a vulnerability report. I could not construct a
collision against the code as it stands today — the property holds. The concern is that it holds by
accident rather than by construction, so an ordinary future refactor could remove it silently.
The invariant
Vote::to_sign_bytesandProposal::to_sign_byteshand raw SSZ to the signer with no domainseparator:
Both flow into the same
sign_bytes()in the remote signer, so nothing structurally prevents asignature over one type from validating as the other. Today they cannot collide because their
encoded lengths are disjoint:
VoteProposalSszRound(Option<u32>) andSszNilOrVal(Option<ValueId>) are variable-length, so both arevariable-size SSZ containers with offset tables — and
Proposalcarries two variableRoundfields to
Vote's one, giving fixed parts of 68 vs 37 bytes. That size difference is what keepsthe length sets apart. (
to_sign_bytesassertsround.is_defined()in both types, so onlyvalue/pol_roundvary.)That is an emergent property of the current field sets. Adding one fixed field to
Vote, or makingValuevariable-length, could make the sets overlap — and nothing in the test suite would notice.Notably,
ValidatorProof::signing_bytesalready does this correctly:An explicit domain tag plus length prefixes.
VoteandProposalhave neither.Why a test and not a domain separator
Prefixing a domain tag in
to_sign_byteswould be the stronger fix, but it changes the signedpreimage and is therefore consensus-breaking — every node would have to upgrade in lockstep or
signatures stop validating. That is a coordinated-rollout decision for maintainers, not something to
slip into a drive-by PR.
This test is the non-breaking half: it changes no runtime behaviour and makes the invariant explicit
and enforced, so if the domain separator is ever wanted, the failure will say so at the right moment.
The test
crates/types/src/proposal.rs, in the existingtestsmodule. It computes both length sets throughthe real
to_sign_bytesand asserts they do not intersect. The failure message names the fix:Verification
I also ran a negative control — temporarily inverting the comparison so the assertion should trip —
to confirm the test can actually fail rather than being vacuously green. It failed with the message
above, then passed again once reverted.
Scope
ValidatorProofis unaffected and already safe by construction.