mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
consensus: require cryptographic quorum evidence
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -3,80 +3,17 @@
|
||||
|
||||
package chain
|
||||
|
||||
import (
|
||||
"time"
|
||||
import "github.com/luxfi/ids"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// ReceiveAuthenticatedVote counts a peer's preference toward the NOVA tally when
|
||||
// the peer's identity came from the authenticated transport.
|
||||
// ReceiveAuthenticatedVote is retained as a source-compatible tombstone for node
|
||||
// versions that once promoted Chits into votes. It always refuses the input.
|
||||
//
|
||||
// THE PROBLEM IT SOLVES. A Chits message carries no signature field, so on a K>1
|
||||
// chain every inbound preference was dropped at the engine's signature gate and α
|
||||
// was unreachable BY CONSTRUCTION: blocks were built, verified, and preferred by
|
||||
// every node while the tally stayed at zero. Testnet sat at 16820 and devnet at
|
||||
// 7323 in exactly this state, each node logging "vote is UNSIGNED and cannot be
|
||||
// counted" once per peer per poll, forever.
|
||||
//
|
||||
// WHY IT IS SAFE, WHEN COUNTING A PAYLOAD-SUPPLIED Vote{NodeID} WOULD NOT BE.
|
||||
// The distinction is where origin comes from, and it is the same one the reference implementation
|
||||
// relies on: its engine receives the voter as `Chits(nodeID, requestID, …)`, a
|
||||
// PARAMETER the router fills in from the authenticated connection, so a peer
|
||||
// cannot claim to be someone else. Lux's Vote carries NodeID as a struct FIELD,
|
||||
// which any peer can set to any value — counting that would make finality
|
||||
// forgeable by anyone who can send 32 bytes.
|
||||
//
|
||||
// So origin is a PARAMETER here too. The caller must be the inbound-message path
|
||||
// that already authenticated the sender; the identity is not taken from anything
|
||||
// the sender serialized. `transportAuthenticated` is unexported, so no decoded
|
||||
// value can carry it.
|
||||
//
|
||||
// WHAT IT DELIBERATELY DOES NOT DO. It contributes to the Nova (liveness) tally
|
||||
// only. It never reaches recordCertVoteLocked, so it can never become part of a
|
||||
// QuorumCert: a cert is a PORTABLE claim that travels to nodes which did not
|
||||
// authenticate this connection, and for those nodes only a signature carries
|
||||
// meaning. Quasar therefore still requires signatures, unchanged — local liveness
|
||||
// rests on transport authentication, exported finality rests on cryptography.
|
||||
// An authenticated connection proves who sent a packet, but a Chits packet contains
|
||||
// no signature over the consensus position or execution result. Counting it created
|
||||
// a second, non-portable acceptance authority and even let a receiver manufacture the
|
||||
// decision bit from its own re-Verify result. Multi-validator progress now comes from
|
||||
// signed BroadcastVote messages; sole-validator progress uses the ordinary K==1
|
||||
// ReceiveVote path.
|
||||
func (t *Transitive) ReceiveAuthenticatedVote(origin ids.NodeID, blockID ids.ID, accept bool) bool {
|
||||
if origin == ids.EmptyNodeID || blockID == ids.Empty {
|
||||
return false
|
||||
}
|
||||
if !t.holdsStake(origin, blockID) {
|
||||
return false
|
||||
}
|
||||
return t.ReceiveVote(Vote{
|
||||
BlockID: blockID,
|
||||
NodeID: origin,
|
||||
Accept: accept,
|
||||
SignedAt: time.Now(),
|
||||
transportAuthenticated: true,
|
||||
})
|
||||
}
|
||||
|
||||
// holdsStake answers whether origin is a validator of this chain at the block's
|
||||
// epoch.
|
||||
//
|
||||
// The signed path never has to ask. VerifyVote resolves the voter's key out of
|
||||
// the set at that epoch, so an id that is not in the set has no key and its vote
|
||||
// dies there — membership is answered as a side effect of checking the
|
||||
// signature. This path has no signature to resolve, and the tallies are keyed by
|
||||
// NodeID, so without asking directly α would count distinct PEERS. A peer need
|
||||
// not be a validator to connect, and its NodeID is a hash of a certificate it
|
||||
// generates for itself, so distinct peers are free to mint.
|
||||
//
|
||||
// A chain with no stake model has no membership to check against, and none to
|
||||
// protect: α there is a plain count over a fixed set whose admission is enforced
|
||||
// before a peer can connect at all. Refusing on its behalf would cost a
|
||||
// single-validator or equal-stake chain every vote it has. The check earns its
|
||||
// keep exactly where a stake model exists, which is every chain where a stranger
|
||||
// could connect without being a validator.
|
||||
func (t *Transitive) holdsStake(origin ids.NodeID, blockID ids.ID) bool {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
if t.stakeSource == nil {
|
||||
return true
|
||||
}
|
||||
return t.stakeSource.Weight(origin, t.epochHeightLocked(t.pendingBlocks[blockID])) > 0
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,17 +5,13 @@ package chain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// TestAuthenticatedVote_ReachesAlphaAndFinalizes is the LIVENESS regression for
|
||||
// the halt. A Chits carries no signature; before ReceiveAuthenticatedVote existed
|
||||
// every inbound preference on a K>1 chain died at the signature gate and α was
|
||||
// unreachable no matter how many honest peers agreed. Testnet held at 16820 and
|
||||
// devnet at 7323 in exactly this state.
|
||||
func TestAuthenticatedVote_ReachesAlphaAndFinalizes(t *testing.T) {
|
||||
// TestAuthenticatedVote_IsRefused pins the tombstone: transport identity does not
|
||||
// turn an unsigned Chits preference into a cryptographic vote.
|
||||
func TestAuthenticatedVote_IsRefused(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
|
||||
alpha := e.consensus.Alpha()
|
||||
@@ -27,24 +23,19 @@ func TestAuthenticatedVote_ReachesAlphaAndFinalizes(t *testing.T) {
|
||||
trackFollowedBlock(e, chainID, blk)
|
||||
|
||||
for i := 0; i < alpha; i++ {
|
||||
if !e.ReceiveAuthenticatedVote(vs.nodeID(i), blk.id, true) {
|
||||
t.Fatalf("validator %d's authenticated preference was not queued", i)
|
||||
if e.ReceiveAuthenticatedVote(vs.nodeID(i), blk.id, true) {
|
||||
t.Fatalf("validator %d's unsigned preference was queued as a vote", i)
|
||||
}
|
||||
}
|
||||
|
||||
if !waitFor(2*time.Second, func() bool { return readVoteTally(e, blk).acceptVotes >= alpha }) {
|
||||
t.Fatalf("HALT REPRODUCED: %d peers, each authenticated by the transport, agreed on the "+
|
||||
"block and the tally is %+v. A Chits has no signature, so if these are dropped α is "+
|
||||
"unreachable BY CONSTRUCTION and the chain stops — which is what testnet 16820 and "+
|
||||
"devnet 7323 were doing.", alpha, readVoteTally(e, blk))
|
||||
if got := readVoteTally(e, blk); got.acceptVotes != 0 || got.voteCount != 0 || got.certVotes != 0 {
|
||||
t.Fatalf("unsigned transport preferences moved the quorum tally: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticatedVote_IsNovaOnly_NeverBuildsACert is the SAFETY half. Transport
|
||||
// authentication is meaningful only to the node that performed it; a QuorumCert is
|
||||
// a portable claim that travels to nodes which did not. So these votes must never
|
||||
// become cert witnesses, no matter how many arrive.
|
||||
func TestAuthenticatedVote_IsNovaOnly_NeverBuildsACert(t *testing.T) {
|
||||
// TestAuthenticatedVote_NeverBuildsACert is the portable-proof half: no number of
|
||||
// unsigned transport messages can produce a certificate witness.
|
||||
func TestAuthenticatedVote_NeverBuildsACert(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
|
||||
|
||||
@@ -55,10 +46,8 @@ func TestAuthenticatedVote_IsNovaOnly_NeverBuildsACert(t *testing.T) {
|
||||
e.ReceiveAuthenticatedVote(vs.nodeID(i), blk.id, true)
|
||||
}
|
||||
|
||||
if got := readVoteTally(e, blk); got.certVotes != 0 {
|
||||
t.Fatalf("EXPORTABLE FINALITY FROM UNSIGNED VOTES: %d cert witnesses were assembled from "+
|
||||
"transport-authenticated preferences (%+v). A cert must be verifiable by a node that "+
|
||||
"never saw this connection, so only signatures may enter it.", got.certVotes, got)
|
||||
if got := readVoteTally(e, blk); got.acceptVotes != 0 || got.certVotes != 0 {
|
||||
t.Fatalf("unsigned transport preferences entered finality state: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +75,9 @@ func TestAuthenticatedVote_PayloadOriginStillRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticatedVote_OneVotePerValidator: transport authentication proves WHO
|
||||
// sent a message, not how many times they may be counted. A peer answering the
|
||||
// same poll repeatedly must move the tally once.
|
||||
func TestAuthenticatedVote_OneVotePerValidator(t *testing.T) {
|
||||
// TestAuthenticatedVote_ReplaysRemainRefused ensures retries cannot revive the
|
||||
// removed unsigned acceptance path.
|
||||
func TestAuthenticatedVote_ReplaysRemainRefused(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
|
||||
alpha := e.consensus.Alpha()
|
||||
@@ -101,9 +89,7 @@ func TestAuthenticatedVote_OneVotePerValidator(t *testing.T) {
|
||||
e.ReceiveAuthenticatedVote(vs.nodeID(0), blk.id, true)
|
||||
}
|
||||
|
||||
if got := readVoteTally(e, blk); got.acceptVotes > 1 {
|
||||
t.Fatalf("ONE PEER REACHED α ALONE: %d authenticated replays from a single validator moved "+
|
||||
"the tally to %d (%+v). Authentication names the sender; it does not entitle them to "+
|
||||
"vote α times.", alpha+3, got.acceptVotes, got)
|
||||
if got := readVoteTally(e, blk); got.acceptVotes != 0 || got.voteCount != 0 {
|
||||
t.Fatalf("%d unsigned replays moved the tally: %+v", alpha+3, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,56 @@ func (t *Transitive) CertForBlock(blockID ids.ID) ([]byte, bool) {
|
||||
return certs.Get(blockID)
|
||||
}
|
||||
|
||||
// VerifyCatchupCertificate verifies that certBytes is a portable quorum proof for
|
||||
// blockBytes without tracking, finalizing, or otherwise mutating consensus state.
|
||||
// Bootstrap frontier discovery uses this read-only predicate to distinguish a peer's
|
||||
// unsigned claim about its tip from a tip backed by the same cryptographic evidence
|
||||
// that the live finalizer accepts.
|
||||
//
|
||||
// The block is parsed only to bind the proof to the local chain, height, canonical
|
||||
// execution commitment, and validator-set epoch. Its state transition is deliberately
|
||||
// not executed here; ordered bootstrap execution still verifies each block when it is
|
||||
// applied by AcceptCatchupBlock.
|
||||
func (rt *Runtime) VerifyCatchupCertificate(ctx context.Context, blockBytes, certBytes []byte) error {
|
||||
if rt.config.VM == nil || len(certBytes) == 0 {
|
||||
return ErrCatchupCertRejected
|
||||
}
|
||||
blk, err := rt.config.VM.ParseBlock(ctx, blockBytes)
|
||||
if err != nil {
|
||||
return errors.Join(ErrCatchupCertRejected, err)
|
||||
}
|
||||
cert, err := UnmarshalQuorumCert(certBytes)
|
||||
if err != nil {
|
||||
return errors.Join(ErrCatchupCertRejected, err)
|
||||
}
|
||||
|
||||
t := rt.Transitive
|
||||
t.mu.RLock()
|
||||
chainID := t.chainID
|
||||
floor := t.consensus.Alpha()
|
||||
if cert.Tier == Nova {
|
||||
floor = NovaSignerFloor(t.consensus.K())
|
||||
}
|
||||
setRootSource := t.setRootSource
|
||||
t.mu.RUnlock()
|
||||
|
||||
if cert.Position.ChainID != chainID ||
|
||||
cert.Position.Height != blk.Height() ||
|
||||
certCanonical(cert) != canonicalIDOf(blk) ||
|
||||
(floor > 0 && cert.Threshold < uint32(floor)) {
|
||||
return ErrCatchupCertRejected
|
||||
}
|
||||
|
||||
epochHeight := pChainHeightOf(blk)
|
||||
if setRootSource != nil && setRootSource.ValidatorSetRoot(epochHeight) != cert.Position.ValidatorSetRoot {
|
||||
return ErrCatchupCertRejected
|
||||
}
|
||||
if err := t.verifyCert(cert, epochHeight); err != nil {
|
||||
return errors.Join(ErrCatchupCertRejected, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcceptCatchupBlock finalizes ONE gap block from a (blockBytes, certBytes) pair
|
||||
// fetched during frontier catch-up. It is the receive-side counterpart of
|
||||
// CertForBlock: parse → local Verify → track → verified-cert finalize.
|
||||
|
||||
@@ -172,6 +172,32 @@ func buildGap(vm *catchupVM, tip *verifyOnceBlock, k int) []*verifyOnceBlock {
|
||||
return gap
|
||||
}
|
||||
|
||||
func TestVerifyCatchupCertificate_ReadOnlyCryptographicFrontierGate(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
vm := newCatchupVM()
|
||||
rt, chainID, _ := newCatchupRuntime(t, vs, 0, vm)
|
||||
blk := newTestBlock(42, ids.GenerateTestID(), "certified-frontier")
|
||||
vm.register(blk)
|
||||
cert := catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 3)
|
||||
|
||||
if err := rt.VerifyCatchupCertificate(context.Background(), blk.bytes, cert); err != nil {
|
||||
t.Fatalf("valid 4-of-5 certified frontier rejected: %v", err)
|
||||
}
|
||||
if blk.VerifyCalls() != 0 || blk.AcceptCalled() != 0 || rt.IsAccepted(blk.id) {
|
||||
t.Fatalf("frontier proof check mutated execution/finality: verify=%d accept=%d accepted=%v",
|
||||
blk.VerifyCalls(), blk.AcceptCalled(), rt.IsAccepted(blk.id))
|
||||
}
|
||||
|
||||
forged := append([]byte(nil), cert...)
|
||||
forged[len(forged)-1] ^= 0xff
|
||||
if err := rt.VerifyCatchupCertificate(context.Background(), blk.bytes, forged); err == nil {
|
||||
t.Fatal("forged frontier certificate verified")
|
||||
}
|
||||
if blk.VerifyCalls() != 0 || blk.AcceptCalled() != 0 || rt.IsAccepted(blk.id) {
|
||||
t.Fatal("rejected frontier proof mutated execution/finality")
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Liveness — a stranded node converges to the tip through the cert path, with no
|
||||
// re-voting. The network will not re-vote an already-finalized height, so the only
|
||||
|
||||
+5
-34
@@ -146,12 +146,6 @@ type Vote struct {
|
||||
// is not separately tracking the block's parent.
|
||||
ParentID ids.ID
|
||||
|
||||
// transportAuthenticated marks a vote whose ORIGIN was supplied as a
|
||||
// PARAMETER by the authenticated transport, rather than read out of the
|
||||
// payload. It is unexported ON PURPOSE: no decoder, no peer, and no caller
|
||||
// outside this package can set it, so it cannot be forged over the wire.
|
||||
// ReceiveAuthenticatedVote is the only constructor that sets it.
|
||||
transportAuthenticated bool
|
||||
// Round is the consensus round the vote was cast in (0 for the first round
|
||||
// at a height). Bound into the signed position.
|
||||
Round uint32
|
||||
@@ -2262,38 +2256,15 @@ func (t *Transitive) handleVote(vote Vote) {
|
||||
// flip is DELETED: vote.Accept is authoritative once the signature checks
|
||||
// out. Single-validator engines (no verifier) skip authentication — the sole
|
||||
// validator's self-vote is the quorum and carries no signature.
|
||||
if t.voteVerifier != nil && !vote.transportAuthenticated {
|
||||
if t.voteVerifier != nil {
|
||||
pos := t.blockPositionLocked(pending, vote.BlockID)
|
||||
msg := canonicalVoteMessageFor(pos, vote.Accept)
|
||||
// Resolve the voter's pubkey at the block's P-CHAIN epoch height (RESIDUAL-B),
|
||||
// the same height the position's set-root commits to.
|
||||
// WHY THIS STAYS FOR PAYLOAD-SUPPLIED ORIGINS.
|
||||
//
|
||||
// the reference counts unsigned chits: its vote path carries
|
||||
// ZERO, while vms/platformvm/warp signs aggregate BLS for anything that must
|
||||
// travel. Signatures belong at the export layer, not the liveness loop, and
|
||||
// this gate is a signature check one layer too far in. Removing it was tried
|
||||
// here and REVERTED, because Lux cannot yet make that safety argument.
|
||||
//
|
||||
// Ava's engine receives the voter as `Chits(nodeID, requestID, …)` — a
|
||||
// PARAMETER supplied by the router from the authenticated connection. The
|
||||
// type guarantees provenance: a peer cannot claim to be someone else.
|
||||
//
|
||||
// Lux's engine receives `Vote{NodeID: …}` — a FIELD. Nothing at this call
|
||||
// site distinguishes a NodeID read from an authenticated transport from one
|
||||
// a caller copied out of message bytes. Counting unsigned votes here would
|
||||
// therefore trust a value the engine cannot verify the origin of, which is
|
||||
// exactly what vote_safety_invariants_test.go refuses: "counting it makes
|
||||
// finality forgeable by any peer that can send a 32-byte preference."
|
||||
//
|
||||
// So the fix has an ORDER, and it is not this one first:
|
||||
// 1. make voter provenance structural — origin as a parameter, so a
|
||||
// self-declared source is unrepresentable rather than merely unlikely;
|
||||
// 2. THEN unsigned transport-authenticated votes can count toward the Nova
|
||||
// tally, as they do there;
|
||||
// 3. Quasar certificates keep requiring signatures either way — that is
|
||||
// the artifact that must convince a stranger.
|
||||
// Doing (2) before (1) trades a liveness bug for a safety bug.
|
||||
// Transport authentication proves who sent a packet; it does not prove that
|
||||
// the peer signed this consensus position or execution result. There is no
|
||||
// unsigned exemption on a quorum chain: Chits remains sampling input, while
|
||||
// BroadcastVote supplies the cryptographic statement counted here.
|
||||
if len(vote.Signature) == 0 || !t.voteVerifier.VerifyVote(vote.NodeID, msg, vote.Signature, t.epochHeightLocked(pending)) {
|
||||
// Unsigned or invalid: not a real vote from this validator at this
|
||||
// position/decision. Drop it — count nothing.
|
||||
|
||||
@@ -87,10 +87,9 @@ func TestAuthVote_StrangerMovesTheRejectTally(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The control. The same call with real validator ids is the intended behavior and
|
||||
// must keep working — this is the halt fix. If this fails the harness is wrong,
|
||||
// not the engine.
|
||||
func TestAuthVote_ValidatorsStillCount(t *testing.T) {
|
||||
// Even a real validator id is insufficient without a signature over the exact
|
||||
// consensus position. Transport identity is not a substitute for that proof.
|
||||
func TestAuthVote_ValidatorsWithoutSignaturesAreRefused(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
e, chainID := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{})
|
||||
alpha := e.consensus.Alpha()
|
||||
@@ -101,7 +100,7 @@ func TestAuthVote_ValidatorsStillCount(t *testing.T) {
|
||||
for i := 0; i < alpha; i++ {
|
||||
e.ReceiveAuthenticatedVote(vs.nodeID(i), blk.id, true)
|
||||
}
|
||||
if !waitFor(2*time.Second, func() bool { return readVoteTally(e, blk).acceptVotes >= alpha }) {
|
||||
t.Fatalf("control failed: real validators did not reach α (%+v)", readVoteTally(e, blk))
|
||||
if waitFor(100*time.Millisecond, func() bool { return readVoteTally(e, blk).acceptVotes >= alpha }) {
|
||||
t.Fatalf("unsigned validator identities reached α (%+v)", readVoteTally(e, blk))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user