Files
zeekayandHanzo Dev f179b47da7 chain: a canonical cert codec, and equivocation evidence that outlives the cert
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]>
2026-08-21 07:15:50 -07:00

280 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}