fix(10-gno): bind validator addresses to pubkeys and bound PartSetHeader.Total during conversion - #367
Open
clockworkgr wants to merge 7 commits into
Open
Conversation
…version ConvertToGnoValidatorSet accepted any syntactically valid, unique address string for a validator without checking that it is derived from the validator's public key, which native gno enforces in its validator set constructor. Since Validator.Bytes() excludes the address and commit sign bytes exclude the validator address and index, a relayer could attach distinct addresses to a single public key and have one signature counted in several slots of a relayer-supplied validator set. - Require address == pubkey.Address() for every validator. - Key the duplicate-address check on the parsed address instead of the raw string, since bech32 decoding is case-insensitive. - Reject ed25519 keys of the wrong length instead of panicking on the slice-to-array conversion, and reject nil validator entries.
clockworkgr
requested review from
giunatale,
jaekwon and
tbruyelle
as code owners
September 10, 2026 08:44
The converters copied the protobuf int64 PartSetHeader.Total into the vendored bfttypes.PartSetHeader with a bare int cast and no bounds check, and none of the ValidateBasic paths cap it. A precommit carrying a Total above the uint32 range therefore survived conversion and Commit.ValidateBasic, then panicked in CanonicalizePartSetHeader when the vote sign bytes were computed during commit verification. The panic is recovered by the SDK and surfaces as a deterministic transaction failure, so the change only improves the error type. - Add convertPartSetHeader, enforcing the [0, MaxBlockPartsCount] bound and hash size that gno's PartSetHeader.ValidateBasic applies, and route the commit, precommit, header last-block-ID and block-ID conversions through it. - ConvertToGnoBlockID now returns an error; both misbehaviour callers updated. - Add converter bounds tests for every cast site and a misbehaviour ValidateBasic regression that previously panicked.
…ests - Reject a precommit Type above math.MaxUint8 in ConvertToGnoCommit, since the conversion to gno's byte-sized SignedMsgType would otherwise truncate a wire value such as 258 into PrecommitType and pass gno's own check. - Clarify that presence of a block ID is checked with IsComplete, not ValidateBasic, in the ConvertToGnoBlockID doc comment. - Refer to gno's CanonicalizePartSetHeader as upstream rather than vendored. - Iterate the parts-header bounds test over a slice for stable subtest order, and assert the forged-set test names the first unbound address.
giunatale
requested changes
Sep 10, 2026
giunatale
left a comment
Collaborator
There was a problem hiding this comment.
couple nits, but overall looking good.
TY!
giunatale
approved these changes
Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two hardening fixes to the
10-gnolight client's protobuf-to-gno conversion layer inmodules/10-gno/helpers.go, both reported through the bug bounty program and both classified as defense-in-depth rather than exploitable vulnerabilities. The first addresses report TMNSC-292, the second addresses report TMNSC-282.1. Validator addresses were not bound to their public keys (TMNSC-292)
ConvertToGnoValidatorSetaccepted any syntactically valid, unique address string for a validator without checking that it is derived from the validator's public key. Native gno enforces this binding in its validator set constructor (validator address doesn't match pubkey), so the light client diverged from gno's own validation.Because
Validator.Bytes()(and thereforeValidatorSet.Hash()) excludes the address, and commit sign bytes exclude the validator address and index, a relayer could attach distinct addresses to a single public key and have one signature counted in several slots of a relayer-supplied validator set.The reported forgery only succeeds when the attacker already holds more than the configured trust level of the trusted validator set, under which a correct light client is forgeable by design (the new set is relayer-supplied and only bound by the header's validators hash). Below the trust level the missing check has no effect.
Changes:
address == pubkey.Address()for every validator.2.
PartSetHeader.Totalwas not bounded before canonicalization (TMNSC-282)The converters copied the protobuf
int64PartSetHeader.Totalinto gno'sbfttypes.PartSetHeaderwith a bareintcast and no bounds check, at four sites (commit block ID, each precommit block ID, header last block ID, andConvertToGnoBlockID). None of theValidateBasicpaths cap it, sincePartSetHeader.ValidateBasicis never invoked.A precommit carrying a
Totalabove the uint32 range therefore survived conversion andCommit.ValidateBasic, then panicked in gno'sCanonicalizePartSetHeaderwhen vote sign bytes were computed during commit verification. The panic is caught by the SDK's recovery middleware and surfaces as a deterministic transaction failure charged to the submitter, so there is no consensus or liveness impact. The upstream gno comment treats this panic as a fail-loud assertion and directs integrators to validate upstream, which is what this does.Changes:
convertPartSetHeader, enforcing the[0, MaxBlockPartsCount]bound and 32-byte-or-empty hash size that gno'sPartSetHeader.ValidateBasicapplies, and route all four cast sites through it. Out-of-range values now return a typedErrInvalidHeader.ConvertToGnoBlockIDnow returns an error; both misbehaviour callers are updated.Typethat does not fit gno's byte-sizedSignedMsgType, so the conversion cannot truncate an out-of-range wire value into the precommit type.State compatibility
These fixes are state-machine-breaking and must ship in a coordinated upgrade rather than as a patch release that nodes adopt independently. The changelog entries sit under State Breaking for that reason.
Behavior only differs for malformed client messages:
ErrInvalidHeader.Legitimate gno headers are unaffected. Because the transaction error code and gas used feed into the results hash, a node on this binary and a node on the previous one would produce different results for such a message and diverge.
Tests
Validator set binding, in
TestConvertToGnoValidatorSet_RejectsMalformedSets:NewValidatorSet, which panics on the same input)Parts header bounds:
TestConvertPartSetHeader_Boundsexercises every cast site with-1,MaxBlockPartsCount + 1,MaxUint32 + 1andMaxInt64(rejected, no panic),0,1andMaxBlockPartsCount(accepted), a 31-byte hash (rejected) and an empty hash (accepted).TestConvertToGnoCommit_RejectsOutOfRangePrecommitTypeshows the narrowing conversion alone would map 258 to the precommit type, and that the converter now rejects it.TestMisbehaviour_ValidateBasic_RejectsOversizedPrecommitPartsTotalreproduces the reported end-to-end path throughMisbehaviour.ValidateBasic. It panics onmainwithPartSetHeader.Total (4294967296) out of canonical uint32 rangeand returns an error with this change.All new cases fail on
mainand pass with this change. The fullmodules/10-gnosuite andmake lintpass.