mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
The cert accept byte decoded with `ab != 0` and re-encoded as 1, so 254 byte values that the encoder can never produce still decoded — two byte strings naming one cert. Anything treating cert bytes as identity (the per-block serving cache, a dedup, a digest) can then be split by flipping a bit no field reads. A canonical codec refuses what it cannot reproduce: the accept byte is 0 or 1, and anything else is corrupt. Ingest dropped the equivocation evidence for a double-sign that arrives AFTER the height certifies. A double-signer racing the honest cert gains nothing, so that is precisely the ordering it picks — and the already-certified early return fired before the subject was recorded, discarding the second-execution evidence along with the vote. The signature verified; only the record of it was skipped. Evidence is a property of the signed votes, not of whether a cert exists, so it is now recorded before the early return. And a nil stake source is a rejection at the top of Ingest rather than a nil dereference at q.stake.ValidatorCount deep inside. The file documents nil as fail-closed; it was a remote crash, because every entry point takes peer data and the first verified attestation reached the deref. Co-authored-by: Hanzo Dev <[email protected]>
280 lines
8.7 KiB
Go
280 lines
8.7 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
// cert_codec.go — deterministic wire codec for the engine-level
|
||
// QuorumCert. A finality cert is gossiped so followers can verify α-of-K
|
||
// finality without re-collecting votes themselves; this is its on-wire form.
|
||
//
|
||
// All multi-byte integers are big-endian. Decoding is fail-closed on every
|
||
// short read and rejects trailing bytes, and caps the attacker-controlled vote
|
||
// count against the remaining buffer BEFORE any allocation (decode-DoS guard).
|
||
package chain
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/luxfi/ids"
|
||
)
|
||
|
||
// ErrQCWireCorrupt is returned by the decoder on any structural defect.
|
||
var ErrQCWireCorrupt = errors.New("chain: quorum cert wire corrupt")
|
||
|
||
// Wire layout (big-endian). The wire carries BOTH the canonical execution
|
||
// identity (the signed primary object) and the outer transport ids (the
|
||
// non-authoritative cache key used for block lookup/gossip):
|
||
//
|
||
// version:2
|
||
// type:1
|
||
// tier:1 (Nova | Quasar — the accept/export rung this cert attests)
|
||
// chain_id:32
|
||
// height:8
|
||
// round:4
|
||
// block_id:32 (OUTER/envelope — transport cache key; NOT signed)
|
||
// parent_id:32 (OUTER/envelope parent — transport; NOT signed)
|
||
// canonical_block_id:32 (inner execution commitment — PRIMARY; signed)
|
||
// parent_canonical_id:32 (inner parent commitment — signed)
|
||
// execution_state_root:32 (post-execution state root — signed)
|
||
// payload_root:32 (tx/payload root — signed)
|
||
// validator_set_root:32 (epoch/weighted-set commitment; Empty = unbound; signed)
|
||
// threshold:4
|
||
// vote_count:4
|
||
// then vote_count records, each:
|
||
// node_id:20 (ids.NodeID is a 20-byte value)
|
||
// accept:1
|
||
// sig_len:4 sig:sig_len
|
||
//
|
||
// The outer ids are encoded so a receiver can locate the block, but they are
|
||
// NOT part of CanonicalVoteMessage — a signature commits only to the canonical
|
||
// identity, so a cert is identical regardless of which envelope wrapped the
|
||
// inner block.
|
||
|
||
// qcVoteFixed is the fixed-size prefix of one encoded vote (node_id + accept +
|
||
// sig_len). Used to cap vote_count against the buffer before allocation.
|
||
const qcVoteFixed = ids.NodeIDLen + 1 + 4
|
||
|
||
// qcHeaderSize is the fixed header byte length preceding the vote records.
|
||
// (version + type + tier + chain_id + height + round + block_id + parent_id +
|
||
// canonical_block_id + parent_canonical_id + execution_state_root + payload_root +
|
||
// validator_set_root + threshold + vote_count.)
|
||
const qcHeaderSize = 2 + 1 + 1 + 32 + 8 + 4 + 32 + 32 + 32 + 32 + 32 + 32 + 32 + 4 + 4
|
||
|
||
// MarshalBinary encodes the cert deterministically. Equal certs encode to equal
|
||
// bytes (votes are kept in the strictly-increasing order Assemble produced).
|
||
func (c *QuorumCert) MarshalBinary() ([]byte, error) {
|
||
if c == nil {
|
||
return nil, ErrQCNil
|
||
}
|
||
buf := make([]byte, 0, qcHeaderSize)
|
||
var u16 [2]byte
|
||
binary.BigEndian.PutUint16(u16[:], c.Version)
|
||
buf = append(buf, u16[:]...)
|
||
buf = append(buf, byte(c.Type))
|
||
buf = append(buf, byte(c.Tier))
|
||
buf = append(buf, c.Position.ChainID[:]...)
|
||
var u64 [8]byte
|
||
binary.BigEndian.PutUint64(u64[:], c.Position.Height)
|
||
buf = append(buf, u64[:]...)
|
||
var u32 [4]byte
|
||
binary.BigEndian.PutUint32(u32[:], c.Position.Round)
|
||
buf = append(buf, u32[:]...)
|
||
buf = append(buf, c.Position.BlockID[:]...)
|
||
buf = append(buf, c.Position.ParentID[:]...)
|
||
buf = append(buf, c.Position.CanonicalID[:]...)
|
||
buf = append(buf, c.Position.ParentCanonicalID[:]...)
|
||
buf = append(buf, c.Position.ExecutionStateRoot[:]...)
|
||
buf = append(buf, c.Position.PayloadRoot[:]...)
|
||
buf = append(buf, c.Position.ValidatorSetRoot[:]...)
|
||
binary.BigEndian.PutUint32(u32[:], c.Threshold)
|
||
buf = append(buf, u32[:]...)
|
||
binary.BigEndian.PutUint32(u32[:], uint32(len(c.Votes)))
|
||
buf = append(buf, u32[:]...)
|
||
|
||
for i := range c.Votes {
|
||
v := &c.Votes[i]
|
||
buf = append(buf, v.NodeID[:]...)
|
||
if v.Accept {
|
||
buf = append(buf, 0x01)
|
||
} else {
|
||
buf = append(buf, 0x00)
|
||
}
|
||
binary.BigEndian.PutUint32(u32[:], uint32(len(v.Signature)))
|
||
buf = append(buf, u32[:]...)
|
||
buf = append(buf, v.Signature...)
|
||
}
|
||
return buf, nil
|
||
}
|
||
|
||
// UnmarshalQuorumCert is the inverse of MarshalBinary. Strict trailing-bytes
|
||
// policy and fail-closed on every short read.
|
||
func UnmarshalQuorumCert(data []byte) (*QuorumCert, error) {
|
||
r := &qcWireReader{buf: data}
|
||
c := &QuorumCert{}
|
||
|
||
v16, err := r.u16()
|
||
if err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
c.Version = v16
|
||
t8, err := r.u8()
|
||
if err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
c.Type = QCType(t8)
|
||
tier8, err := r.u8()
|
||
if err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
c.Tier = Finality(tier8)
|
||
if err = r.readIDInto(&c.Position.ChainID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if c.Position.Height, err = r.u64(); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if c.Position.Round, err = r.u32(); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.BlockID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.ParentID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.CanonicalID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.ParentCanonicalID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.ExecutionStateRoot); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.PayloadRoot); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if err = r.readIDInto(&c.Position.ValidatorSetRoot); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
if c.Threshold, err = r.u32(); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
voteCount, err := r.u32()
|
||
if err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
|
||
// Cap vote_count against the remaining buffer BEFORE reserving capacity:
|
||
// each vote occupies at least qcVoteFixed bytes, so a count whose minimum
|
||
// footprint exceeds the bytes that remain is structurally impossible.
|
||
// Rejects an adversarial header (vote_count = 0xFFFFFFFF) in O(1).
|
||
if uint64(voteCount)*uint64(qcVoteFixed) > uint64(len(r.buf)) {
|
||
return nil, fmt.Errorf("%w: vote_count %d exceeds remaining buffer (%d bytes)",
|
||
ErrQCWireCorrupt, voteCount, len(r.buf))
|
||
}
|
||
c.Votes = make([]SignedVote, 0, voteCount)
|
||
for i := uint32(0); i < voteCount; i++ {
|
||
var sv SignedVote
|
||
if err = r.readNodeIDInto(&sv.NodeID); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
ab, err := r.u8()
|
||
if err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
// The encoder emits 0 or 1, so the decoder accepts 0 or 1. `ab != 0` took
|
||
// any of 254 other bytes as true and re-encoded them as 1, so a frame the
|
||
// encoder could never produce still decoded — two byte strings naming one
|
||
// cert. Anything that treats cert bytes as identity (the per-block serving
|
||
// cache, a dedup, a digest) can then be split by flipping a bit no field
|
||
// reads. A canonical codec refuses what it cannot reproduce.
|
||
if ab > 1 {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
sv.Accept = ab == 1
|
||
if sv.Signature, err = r.lenPrefixed(); err != nil {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
c.Votes = append(c.Votes, sv)
|
||
}
|
||
|
||
if len(r.buf) != 0 {
|
||
return nil, fmt.Errorf("%w: %d trailing bytes", ErrQCWireCorrupt, len(r.buf))
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
// qcWireReader is a bounds-checked sequential reader for the cert codec.
|
||
type qcWireReader struct{ buf []byte }
|
||
|
||
func (r *qcWireReader) need(n int) bool { return len(r.buf) >= n }
|
||
|
||
func (r *qcWireReader) u8() (uint8, error) {
|
||
if !r.need(1) {
|
||
return 0, ErrQCWireCorrupt
|
||
}
|
||
v := r.buf[0]
|
||
r.buf = r.buf[1:]
|
||
return v, nil
|
||
}
|
||
|
||
func (r *qcWireReader) u16() (uint16, error) {
|
||
if !r.need(2) {
|
||
return 0, ErrQCWireCorrupt
|
||
}
|
||
v := binary.BigEndian.Uint16(r.buf[:2])
|
||
r.buf = r.buf[2:]
|
||
return v, nil
|
||
}
|
||
|
||
func (r *qcWireReader) u32() (uint32, error) {
|
||
if !r.need(4) {
|
||
return 0, ErrQCWireCorrupt
|
||
}
|
||
v := binary.BigEndian.Uint32(r.buf[:4])
|
||
r.buf = r.buf[4:]
|
||
return v, nil
|
||
}
|
||
|
||
func (r *qcWireReader) u64() (uint64, error) {
|
||
if !r.need(8) {
|
||
return 0, ErrQCWireCorrupt
|
||
}
|
||
v := binary.BigEndian.Uint64(r.buf[:8])
|
||
r.buf = r.buf[8:]
|
||
return v, nil
|
||
}
|
||
|
||
func (r *qcWireReader) readIDInto(dst *ids.ID) error {
|
||
if !r.need(ids.IDLen) {
|
||
return ErrQCWireCorrupt
|
||
}
|
||
copy(dst[:], r.buf[:ids.IDLen])
|
||
r.buf = r.buf[ids.IDLen:]
|
||
return nil
|
||
}
|
||
|
||
func (r *qcWireReader) readNodeIDInto(dst *ids.NodeID) error {
|
||
if !r.need(ids.NodeIDLen) {
|
||
return ErrQCWireCorrupt
|
||
}
|
||
copy(dst[:], r.buf[:ids.NodeIDLen])
|
||
r.buf = r.buf[ids.NodeIDLen:]
|
||
return nil
|
||
}
|
||
|
||
func (r *qcWireReader) lenPrefixed() ([]byte, error) {
|
||
n, err := r.u32()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if uint64(n) > uint64(len(r.buf)) {
|
||
return nil, ErrQCWireCorrupt
|
||
}
|
||
out := make([]byte, n)
|
||
copy(out, r.buf[:n])
|
||
r.buf = r.buf[n:]
|
||
return out, nil
|
||
}
|