harden(consensus/quasar): close adversarial-review findings on the verify gate

Red adversarial review of the PQ-finality gate. Dormant default verified a true
no-op; these harden the ACTIVATED path before any forward-dated activation:

H1 (HIGH) — bindCheck now binds cert.Epoch == cp.Epoch (and cert.Round). The
  gate resolves verification keys from the cert's epoch; an unbound epoch let a
  cert signed under a DIFFERENT validator-set era (e.g. a compromised RETIRED
  committee key) certify the current block, nullifying KeyEra rotation as a
  blast-radius bound. Honest certs match (producer signs Subject.Epoch==cp.Epoch).
  + wrong-epoch / wrong-round anti-replay test cases.

M2 (MED) — activation is now HEIGHT-ONLY, deterministic. Removed time.Now() (and
  the ActivationConfig.Time field) from the accept path: wall-clock gating split
  finalization across validators with skewed clocks (some halting on a missing
  cert, others finalizing without one). A height is consensus-agreed; timestamp
  forward-dating is expressed by choosing the activation height.

L5 (LOW) — an activated gate with a nil store/validator provider now fails
  closed with ErrGateMisconfigured instead of panicking in the accept hook (a
  panic would halt the chain uncontrollably). Off the dormant path. + test.

M3 (doc) — made the StateRoot=0 transitive-through-BlockHash contract explicit in
  bindCheck (non-zero cert StateRoot already rejected + tested) so the producer
  follow-on cannot silently ship state-committing certs that self-halt.

proposervm: verifyQuasarFinality short-circuits on nil gate BEFORE building the
  Checkpoint, so the default path makes zero block-accessor calls.

13 gate tests green under -race; full proposervm suite green; go build ./... exit 0.
Deferred to follow-ons (gate is dormant + unwired in production): M4 cert-
unavailability halt runbook + grace window, L6 MemCertStore eviction (no ingest
caller yet), proposervm Accept-path integration test, positive valid-cert test
(needs consensus to export cert encoders).
This commit is contained in:
zeekay
2026-06-28 07:49:17 -07:00
parent 463d2533af
commit e15ac35c94
4 changed files with 78 additions and 45 deletions
+4
View File
@@ -31,4 +31,8 @@ var (
// ErrPolicyMismatch — the cert's PolicyID is not the configured policy. A
// cert cannot select its own (weaker) posture.
ErrPolicyMismatch = errors.New("quasar: cert policy id does not match configured policy")
// ErrGateMisconfigured — the gate is activated at a checkpoint but has no
// cert store or validator provider. Fail closed rather than panic.
ErrGateMisconfigured = errors.New("quasar: gate activated but missing store or validator provider")
)
+38 -26
View File
@@ -41,7 +41,6 @@ package quasar
import (
"fmt"
"time"
qcert "github.com/luxfi/consensus/protocol/quasar"
)
@@ -49,35 +48,28 @@ import (
// ActivationConfig is the forward-dated activation switch.
//
// The zero value is DORMANT: Height == 0 means "never activate" and the gate is
// a no-op for every block. This mirrors the genesis upgrade-timestamp discipline
// (a far-future / unset activation point cannot affect live finality).
// a no-op for every block. This mirrors the genesis upgrade discipline (a
// far-future / unset activation point cannot affect live finality).
//
// Activation is by HEIGHT ONLY, deliberately. A block height is agreed by
// consensus, so every honest validator enforces PQ finality at exactly the SAME
// checkpoints — there is no node-local decision. (A wall-clock gate would split
// finalization across validators with skewed clocks: some halting on a missing
// cert while others finalize without one. Timestamp-based forward-dating is
// expressed by choosing the activation HEIGHT at the target time.)
type ActivationConfig struct {
// Height is the block height at and above which PQ-finality verification is
// enforced at checkpoints. 0 == dormant (never).
Height uint64
// Time, when non-zero, ADDITIONALLY requires the wall-clock activation
// moment to have passed before enforcement begins (defence in depth on the
// forward-dating). Zero == height-only gating.
Time time.Time
}
// dormant reports whether the activation is unset (the default — never enforce).
func (a ActivationConfig) dormant() bool { return a.Height == 0 }
// active reports whether enforcement is live for a block at the given height and
// the given wall-clock now. Dormant activation is never active.
func (a ActivationConfig) active(height uint64, now time.Time) bool {
if a.Height == 0 {
return false
}
if height < a.Height {
return false
}
if !a.Time.IsZero() && now.Before(a.Time) {
return false
}
return true
// active reports whether enforcement is live for a block at the given height.
// Deterministic: height-only, no wall clock. Dormant activation is never active.
func (a ActivationConfig) active(height uint64) bool {
return a.Height != 0 && height >= a.Height
}
// DefaultCheckpointInterval is the default checkpoint cadence in blocks. PQ
@@ -177,12 +169,18 @@ func (g *Gate) VerifyAccepted(cp Checkpoint) error {
if g == nil || g.cfg.Activation.dormant() {
return nil
}
if !g.cfg.Activation.active(cp.Height, time.Now()) {
if !g.cfg.Activation.active(cp.Height) {
return nil
}
if !g.isCheckpoint(cp.Height) {
return nil
}
// Activated checkpoint: the gate MUST have its cert store + validator
// provider, or it cannot verify. Fail closed with a typed error rather than
// panic in the accept hook (a panic would halt the chain uncontrollably).
if g.store == nil || g.validators == nil {
return fmt.Errorf("%w: chain=%d height=%d", ErrGateMisconfigured, g.cfg.ChainID, cp.Height)
}
cert, ok := g.store.Lookup(g.cfg.ChainID, cp.Height, cp.BlockID)
if !ok || cert == nil {
@@ -209,7 +207,7 @@ func (g *Gate) Activated(height uint64) bool {
if g == nil {
return false
}
return g.cfg.Activation.active(height, time.Now())
return g.cfg.Activation.active(height)
}
// IsCheckpoint reports whether the given height is a checkpoint under the gate's
@@ -239,15 +237,29 @@ func bindCheck(cert *qcert.ConsensusCert, chainID uint32, cp Checkpoint) error {
if cert.ChainID != chainID {
return fmt.Errorf("%w: cert chain %d != finalized chain %d", ErrFinalityCertMismatch, cert.ChainID, chainID)
}
// Bind the epoch. The gate resolves the verification keys from the cert's
// epoch, so an UNBOUND epoch would let a cert signed under a DIFFERENT
// validator-set era (e.g. a compromised RETIRED committee's group key) certify
// the current block — nullifying KeyEra rotation as a blast-radius bound. The
// honest producer signs over Subject.Epoch == cp.Epoch, so honest certs match.
if cert.Epoch != cp.Epoch {
return fmt.Errorf("%w: cert epoch %d != finalized epoch %d", ErrFinalityCertMismatch, cert.Epoch, cp.Epoch)
}
if cert.Round != cp.Round {
return fmt.Errorf("%w: cert round %d != finalized round %d", ErrFinalityCertMismatch, cert.Round, cp.Round)
}
if cert.Height != cp.Height {
return fmt.Errorf("%w: cert height %d != finalized height %d", ErrFinalityCertMismatch, cert.Height, cp.Height)
}
if cert.BlockHash != cp.BlockID {
return fmt.Errorf("%w: cert block hash != finalized block id", ErrFinalityCertMismatch)
}
// StateRoot binds only when the cert commits it. A zero cert StateRoot means
// "committed transitively through BlockHash" (the envelope spec), so it is
// not cross-checked.
// StateRoot contract: at the proposervm layer the post-state root is
// committed TRANSITIVELY through BlockHash, so cp.StateRoot is zero and the
// cert MUST carry a zero StateRoot too. A non-zero cert StateRoot is rejected
// (no state to cross-check here) — the producer follow-on MUST emit
// StateRoot==0 at this layer; a chain that wants an explicit state binding
// plumbs cp.StateRoot AND signs it, and this check then enforces equality.
var zero [32]byte
if cert.StateRoot != zero && cert.StateRoot != cp.StateRoot {
return fmt.Errorf("%w: cert state root != finalized state root", ErrFinalityCertMismatch)
+31 -19
View File
@@ -7,7 +7,6 @@ import (
"context"
"errors"
"testing"
"time"
qcert "github.com/luxfi/consensus/protocol/quasar"
)
@@ -79,18 +78,6 @@ func TestBelowActivationIsNoop(t *testing.T) {
}
}
// TestActivationTimeNotReached — height is past the activation height but the
// forward-dated wall-clock moment has not arrived => still dormant.
func TestActivationTimeNotReached(t *testing.T) {
g := NewGate(Config{
Activation: ActivationConfig{Height: 10, Time: time.Now().Add(time.Hour)},
CheckpointInterval: 10,
}, NewMemCertStore(), StaticValidatorSetProvider{Set: testValidators()})
if err := g.VerifyAccepted(checkpointAt(10)); err != nil {
t.Fatalf("pre-activation-time must be a no-op, got %v", err)
}
}
// TestNonCheckpointIsNoop — activated and at/above activation height, but the
// height is not a checkpoint => nil (certs ride checkpoints only).
func TestNonCheckpointIsNoop(t *testing.T) {
@@ -122,14 +109,18 @@ func TestMissingCertFailsClosed(t *testing.T) {
// (wrong block id / height / chain) is rejected by bindCheck before any crypto.
// Anti-replay: a valid cert for a different block must not satisfy this one.
func TestMismatchedCertRejected(t *testing.T) {
// cp = checkpointAt(20) has Epoch 1, Round 0. Each case sets every binding
// field correctly EXCEPT the one named, so it fails at that field.
cases := []struct {
name string
cert *qcert.ConsensusCert
}{
{"wrong block", &qcert.ConsensusCert{ChainID: 1337, Height: 20, BlockHash: [32]byte{0x99}}},
{"wrong height", &qcert.ConsensusCert{ChainID: 1337, Height: 21, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
{"wrong chain", &qcert.ConsensusCert{ChainID: 7, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
{"wrong state root", &qcert.ConsensusCert{ChainID: 1337, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}, StateRoot: [32]byte{0x55}}},
{"wrong block", &qcert.ConsensusCert{ChainID: 1337, Epoch: 1, Height: 20, BlockHash: [32]byte{0x99}}},
{"wrong height", &qcert.ConsensusCert{ChainID: 1337, Epoch: 1, Height: 21, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
{"wrong chain", &qcert.ConsensusCert{ChainID: 7, Epoch: 1, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
{"wrong epoch", &qcert.ConsensusCert{ChainID: 1337, Epoch: 2, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
{"wrong round", &qcert.ConsensusCert{ChainID: 1337, Epoch: 1, Round: 1, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}}},
{"wrong state root", &qcert.ConsensusCert{ChainID: 1337, Epoch: 1, Height: 20, BlockHash: [32]byte{0xab, 0xcd, 0xef}, StateRoot: [32]byte{0x55}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -186,20 +177,41 @@ func TestDelegatesToVerifier(t *testing.T) {
// provider has no set for the epoch => ErrValidatorSetUnavailable (fail closed).
func TestValidatorSetUnavailable(t *testing.T) {
cp := checkpointAt(20)
cert := &qcert.ConsensusCert{Version: 1, ChainID: 1337, Height: cp.Height, BlockHash: cp.BlockID}
// Bind correctly (epoch included) so the cert passes bindCheck and the
// failure is specifically the unavailable validator set.
cert := &qcert.ConsensusCert{Version: 1, ChainID: 1337, Epoch: cp.Epoch, Height: cp.Height, BlockHash: cp.BlockID}
store := NewMemCertStore()
store.Put(cert)
g := NewGate(Config{
ChainID: 1337,
Activation: ActivationConfig{Height: 10},
CheckpointInterval: 10,
}, store, StaticValidatorSetProvider{Set: nil}) // no set
}, store, StaticValidatorSetProvider{Set: nil}) // provider present, no set
err := g.VerifyAccepted(cp)
if !errors.Is(err, ErrValidatorSetUnavailable) {
t.Fatalf("want ErrValidatorSetUnavailable, got %v", err)
}
}
// TestGateMisconfiguredFailsClosed — an activated gate at a checkpoint with no
// cert store (or no validator provider) fails closed with a typed error rather
// than panicking in the accept hook.
func TestGateMisconfiguredFailsClosed(t *testing.T) {
g := NewGate(Config{
ChainID: 1337,
Activation: ActivationConfig{Height: 10},
CheckpointInterval: 10,
}, nil, nil) // no store, no validators
if err := g.VerifyAccepted(checkpointAt(20)); !errors.Is(err, ErrGateMisconfigured) {
t.Fatalf("want ErrGateMisconfigured, got %v", err)
}
// dormant misconfigured gate is still a no-op (guard is post-activation).
gd := NewGate(Config{ChainID: 1337, CheckpointInterval: 10}, nil, nil)
if err := gd.VerifyAccepted(checkpointAt(20)); err != nil {
t.Fatalf("dormant gate must be a no-op even if misconfigured, got %v", err)
}
}
// --- producer scaffolding ---
type stubProducer struct {
+5
View File
@@ -137,6 +137,11 @@ func (vm *VM) SetQuasarGate(g *pqfinality.Gate) { vm.quasarGate = g }
// StateRoot is left zero here (committed transitively through the block id), so
// the cert's StateRoot binding is not cross-checked at this layer.
func (vm *VM) verifyQuasarFinality(b *postForkBlock) error {
// Fast path: no gate (the default) => zero cost, no block-accessor calls, no
// checkpoint build. The classical accept path is untouched.
if vm.quasarGate == nil {
return nil
}
return vm.quasarGate.VerifyAccepted(pqfinality.Checkpoint{
Epoch: b.PChainEpoch().Number,
Height: b.Height(),