mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user