mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
1126 lines
44 KiB
Go
1126 lines
44 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// consensus_cert_test.go — the ConsensusCert ENVELOPE attack corpus. Every
|
|
// named test asserts an ATTACK IS REJECTED (a typed error, never a panic). The
|
|
// envelope sits on the already-green WeightedQuorumCert foundation; these tests
|
|
// pin the 13 envelope invariants at the envelope level, reusing the foundation's
|
|
// real-key signer harness (quorum_cert_test.go) for the weighted-sig-set leg.
|
|
package quasar
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/luxfi/crypto/bls"
|
|
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
|
|
)
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Envelope test harness — real policy / validator-set / cert over the
|
|
// foundation's real-key WeightedQuorumCert.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// envPolicy is a concrete ConsensusCertPolicy for tests. The required-leg set,
|
|
// the allowed (kind, mode, param) triples, the threshold weight, and the
|
|
// classical-scheme allow-list are all explicit and POLICY-owned.
|
|
type envPolicy struct {
|
|
required []LegSpec
|
|
allow map[legModeParam]bool
|
|
thresholdWeight uint64
|
|
classical map[ClassicalScheme]bool
|
|
}
|
|
|
|
type legModeParam struct {
|
|
kind LegKind
|
|
mode EvidenceMode
|
|
param uint8
|
|
}
|
|
|
|
func (p *envPolicy) RequiredLegs() []LegSpec { return p.required }
|
|
func (p *envPolicy) Allows(leg LegSpec, mode EvidenceMode, paramSet uint8) bool {
|
|
return p.allow[legModeParam{leg.Kind, mode, paramSet}]
|
|
}
|
|
func (p *envPolicy) ThresholdWeight() uint64 { return p.thresholdWeight }
|
|
func (p *envPolicy) AllowsClassicalScheme(scheme ClassicalScheme) bool {
|
|
return p.classical[scheme]
|
|
}
|
|
|
|
// envStore resolves a single fixed policy (the store is keyed in production by
|
|
// (chain, epoch, policy-id); tests pin one policy and assert the lookup args
|
|
// flow through).
|
|
type envStore struct {
|
|
policy ConsensusCertPolicy
|
|
err error
|
|
}
|
|
|
|
func (s *envStore) Policy(chainID uint32, epoch uint64, policyID uint32) (ConsensusCertPolicy, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
return s.policy, nil
|
|
}
|
|
|
|
// envValidators wraps the foundation scenario as a ConsensusValidatorSet.
|
|
type envValidators struct {
|
|
root [48]byte
|
|
epoch uint64
|
|
cfg QuorumVerifierConfig
|
|
env QuorumMessageEnvelope
|
|
pulsarKey []byte
|
|
coronaKey *coronaThreshold.GroupKey
|
|
classKeys map[ClassicalScheme][]byte
|
|
}
|
|
|
|
func (v *envValidators) Root() [48]byte { return v.root }
|
|
func (v *envValidators) Epoch() uint64 { return v.epoch }
|
|
func (v *envValidators) WeightedConfig() QuorumVerifierConfig { return v.cfg }
|
|
func (v *envValidators) WeightedEnvelope() QuorumMessageEnvelope { return v.env }
|
|
func (v *envValidators) ThresholdGroupKey(kind LegKind) (ThresholdGroupKey, bool) {
|
|
switch kind {
|
|
case LegPulsarMLDSA:
|
|
if len(v.pulsarKey) == 0 {
|
|
return ThresholdGroupKey{}, false
|
|
}
|
|
return ThresholdGroupKey{Kind: LegPulsarMLDSA, PulsarGroupKey: v.pulsarKey}, true
|
|
case LegCoronaLattice:
|
|
if v.coronaKey == nil {
|
|
return ThresholdGroupKey{}, false
|
|
}
|
|
return ThresholdGroupKey{Kind: LegCoronaLattice, CoronaGroupKey: v.coronaKey}, true
|
|
default:
|
|
return ThresholdGroupKey{}, false
|
|
}
|
|
}
|
|
func (v *envValidators) ClassicalAggregateKey(scheme ClassicalScheme) ([]byte, bool) {
|
|
k, ok := v.classKeys[scheme]
|
|
return k, ok
|
|
}
|
|
|
|
// envFixture is a complete, VALID ConsensusCert over a single weighted-sig-set
|
|
// leg, plus the policy + validator set that verify it. The starting point for
|
|
// every attack test (mutate one thing, assert rejection).
|
|
type envFixture struct {
|
|
store *envStore
|
|
validators *envValidators
|
|
policy *envPolicy
|
|
cert *ConsensusCert
|
|
}
|
|
|
|
// pqParam is the ML-DSA-65 parameter byte the weighted-sig-set leg is built
|
|
// under (matches QuorumSchemeMLDSA65 = 0x42).
|
|
const pqParam = uint8(QuorumSchemeMLDSA65)
|
|
|
|
// buildEnvFixture assembles a valid single-leg (Pulsar/weighted-sig-set)
|
|
// ConsensusCert over the foundation's real 4-signer ML-DSA scenario.
|
|
func buildEnvFixture(t *testing.T) envFixture {
|
|
t.Helper()
|
|
sc := fourMLDSA(t) // real inner WeightedQuorumCert, Σweight=100, threshold=100
|
|
|
|
innerWire, err := sc.cert.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("marshal inner cert: %v", err)
|
|
}
|
|
|
|
requiredLeg := LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam}
|
|
policy := &envPolicy{
|
|
required: []LegSpec{requiredLeg},
|
|
allow: map[legModeParam]bool{
|
|
{LegPulsarMLDSA, EvidenceWeightedSigSet, pqParam}: true,
|
|
},
|
|
thresholdWeight: 100,
|
|
classical: map[ClassicalScheme]bool{},
|
|
}
|
|
store := &envStore{policy: policy}
|
|
|
|
validators := &envValidators{
|
|
root: sc.cert.ValidatorSetRoot,
|
|
epoch: sc.env.Epoch,
|
|
cfg: sc.cfg, // MinThreshold gets re-pinned by the leg
|
|
env: sc.env,
|
|
classKeys: map[ClassicalScheme][]byte{},
|
|
}
|
|
|
|
cert := newCertForBH(policy, sc.cert.ChainID, sc.cert.Epoch, sc.cert.Height, sc.cert.Round,
|
|
sc.cert.ValueHash, sc.cert.ValidatorSetRoot,
|
|
[]LegEvidence{{
|
|
Leg: requiredLeg,
|
|
Mode: EvidenceWeightedSigSet,
|
|
Payload: innerWire,
|
|
}})
|
|
cert.AggregateWeight = sc.cert.AggregateWeight
|
|
|
|
return envFixture{store: store, validators: validators, policy: policy, cert: cert}
|
|
}
|
|
|
|
// newCertForBH is the cert constructor: explicit [32]byte block hash, a
|
|
// correctly-derived RequiredLegsRoot (from policy), and a fixed signer root.
|
|
// Tests that attack the required-legs root / signer root override them after
|
|
// construction.
|
|
func newCertForBH(policy ConsensusCertPolicy, chainID uint32, epoch, height uint64, round uint32,
|
|
blockHash [32]byte, vsetRoot [48]byte, evidence []LegEvidence) *ConsensusCert {
|
|
var signerRoot [32]byte
|
|
for i := range signerRoot {
|
|
signerRoot[i] = 0x5A
|
|
}
|
|
return &ConsensusCert{
|
|
Version: consensusCertVersion,
|
|
Profile: 1,
|
|
ChainID: chainID,
|
|
Epoch: epoch,
|
|
Height: height,
|
|
Round: round,
|
|
BlockHash: blockHash,
|
|
ValidatorSetRoot: vsetRoot,
|
|
PolicyID: 7,
|
|
RequiredLegsRoot: HashRequiredLegs(policy.RequiredLegs()),
|
|
SignerRoot: signerRoot,
|
|
Evidence: evidence,
|
|
}
|
|
}
|
|
|
|
// bindBlockHash sets the envelope block hash to the inner cert's ValueHash (the
|
|
// weighted-sig-set leg cross-checks them). fourMLDSA's inner ValueHash is the
|
|
// fixed 0xAB.. [32]byte.
|
|
func (f *envFixture) bindBlockHash(t *testing.T) {
|
|
t.Helper()
|
|
inner := scenarioFromFixture(t, f)
|
|
f.cert.BlockHash = inner.ValueHash
|
|
}
|
|
|
|
// scenarioFromFixture re-decodes the inner cert from the fixture's evidence so
|
|
// tests can read the inner cert's fields (e.g. its ValueHash).
|
|
func scenarioFromFixture(t *testing.T, f *envFixture) *WeightedQuorumCert {
|
|
t.Helper()
|
|
for _, ev := range f.cert.Evidence {
|
|
if ev.Mode == EvidenceWeightedSigSet {
|
|
inner, err := UnmarshalWeightedQuorumCert(ev.Payload)
|
|
if err != nil {
|
|
t.Fatalf("decode inner: %v", err)
|
|
}
|
|
return inner
|
|
}
|
|
}
|
|
t.Fatal("no weighted-sig-set evidence in fixture")
|
|
return nil
|
|
}
|
|
|
|
// validFixture builds a fixture whose envelope block hash is bound to the inner
|
|
// cert's value hash — i.e. a cert that VERIFIES. The base for mutate-one tests.
|
|
func validFixture(t *testing.T) envFixture {
|
|
t.Helper()
|
|
f := buildEnvFixture(t)
|
|
f.bindBlockHash(t)
|
|
return f
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Sanity: the valid fixture verifies (so a rejection in an attack test is the
|
|
// attack, not a broken fixture).
|
|
// ----------------------------------------------------------------------------
|
|
|
|
func TestConsensusCert_HappyPath(t *testing.T) {
|
|
f := validFixture(t)
|
|
if err := VerifyConsensusCert(f.store, f.validators, f.cert); err != nil {
|
|
t.Fatalf("valid consensus cert rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I1/I2 — required legs are POLICY-derived, never cert-derived.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestRejectsCertSuppliedRequiredLegs proves the cert cannot weaken itself by
|
|
// claiming a SMALLER required-leg set. We shrink the cert's RequiredLegsRoot to
|
|
// the commitment of an empty set; the verifier recomputes the root from POLICY
|
|
// and rejects.
|
|
func TestRejectsCertSuppliedRequiredLegs(t *testing.T) {
|
|
f := validFixture(t)
|
|
// Forge: claim a required-legs root for a DIFFERENT (empty) leg set — the
|
|
// cert trying to assert its own (weaker) requirement.
|
|
f.cert.RequiredLegsRoot = HashRequiredLegs(nil)
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrRequiredLegsRootMismatch) {
|
|
t.Fatalf("cert-supplied required legs err = %v, want ErrRequiredLegsRootMismatch", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsRequiredLegsRootMismatch is the direct mismatch: any tamper of the
|
|
// RequiredLegsRoot away from HashRequiredLegs(policy.RequiredLegs()) is rejected.
|
|
func TestRejectsRequiredLegsRootMismatch(t *testing.T) {
|
|
f := validFixture(t)
|
|
f.cert.RequiredLegsRoot[0] ^= 0xFF
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrRequiredLegsRootMismatch) {
|
|
t.Fatalf("required-legs-root tamper err = %v, want ErrRequiredLegsRootMismatch", err)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I5/I12 — every required leg must have evidence.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestRejectsMissingRequiredPQLeg: the policy requires a Pulsar PQ leg but the
|
|
// cert carries no evidence for it. The verifier rejects (the required-legs root
|
|
// still matches because the requirement is policy-derived; the evidence is what
|
|
// is missing).
|
|
func TestRejectsMissingRequiredPQLeg(t *testing.T) {
|
|
f := validFixture(t)
|
|
// Strip the evidence but keep the (policy-derived) required-legs root intact.
|
|
f.cert.Evidence = nil
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrMissingRequiredLeg) {
|
|
t.Fatalf("missing required PQ leg err = %v, want ErrMissingRequiredLeg", err)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I11 — classical-only is forbidden under a PQ policy.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestRejectsClassicalOnlyUnderPQPolicy: the policy requires a Pulsar PQ leg;
|
|
// the cert offers ONLY a classical aggregate (no PQ evidence). The envelope must
|
|
// reject — classical cannot stand in for a required PQ leg.
|
|
func TestRejectsClassicalOnlyUnderPQPolicy(t *testing.T) {
|
|
f := validFixture(t)
|
|
// Replace the PQ evidence with a classical aggregate under a Classical leg
|
|
// kind. The policy STILL requires LegPulsarMLDSA, so the Pulsar leg has no
|
|
// evidence → ErrMissingRequiredLeg (the envelope-level classical-only guard:
|
|
// a required PQ leg is unsatisfied).
|
|
f.cert.Evidence = []LegEvidence{{
|
|
Leg: LegSpec{Kind: LegClassical, ParamSetID: uint8(ClassicalSchemeBLS12381)},
|
|
Mode: EvidenceClassicalAggregate,
|
|
Payload: encodeClassicalAggregatePayload(&ClassicalAggregatePayload{
|
|
Scheme: ClassicalSchemeBLS12381, Payload: []byte{0x01},
|
|
}),
|
|
}}
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrMissingRequiredLeg) {
|
|
t.Fatalf("classical-only under PQ policy err = %v, want ErrMissingRequiredLeg", err)
|
|
}
|
|
}
|
|
|
|
// TestClassicalAggregateCannotSatisfyPQLeg: even if the cert LABELS classical
|
|
// evidence with a PQ leg kind, the classical-aggregate verifier refuses (I10).
|
|
// We make the policy ALLOW the (Pulsar, ClassicalAggregate, param) triple to get
|
|
// past the policy gate and reach the helper, proving the helper itself enforces
|
|
// kind == LegClassical.
|
|
func TestClassicalAggregateCannotSatisfyPQLeg(t *testing.T) {
|
|
f := validFixture(t)
|
|
p := f.policy
|
|
// Allow the mislabeled triple so dispatch reaches VerifyClassicalAggregateLeg.
|
|
p.allow[legModeParam{LegPulsarMLDSA, EvidenceClassicalAggregate, pqParam}] = true
|
|
f.cert.Evidence = []LegEvidence{{
|
|
Leg: LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam}, // PQ kind...
|
|
Mode: EvidenceClassicalAggregate, // ...classical evidence
|
|
Payload: encodeClassicalAggregatePayload(&ClassicalAggregatePayload{
|
|
Scheme: ClassicalSchemeBLS12381, Payload: []byte{0x01},
|
|
}),
|
|
}}
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrClassicalCannotSatisfyPQLeg) {
|
|
t.Fatalf("classical evidence on PQ leg err = %v, want ErrClassicalCannotSatisfyPQLeg", err)
|
|
}
|
|
}
|
|
|
|
// TestClassicalAggregateAcceptedOnlyWithRequiredPQLegs proves the positive
|
|
// case: under a policy that requires BOTH a PQ leg (satisfied by weighted-sig-set
|
|
// PQ evidence) AND a classical leg, the classical aggregate is accepted — but
|
|
// only because the PQ leg was satisfied first. We use a real BLS aggregate.
|
|
func TestClassicalAggregateAcceptedOnlyWithRequiredPQLegs(t *testing.T) {
|
|
f := validFixture(t)
|
|
sc := scenarioFromFixture(t, &f)
|
|
|
|
// Build a real BLS aggregate over the ENVELOPE message (what the classical
|
|
// leg verifies against). The envelope message depends on the cert header,
|
|
// which includes the required-legs root — so we compute it AFTER fixing the
|
|
// two-leg policy.
|
|
pqLeg := LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam}
|
|
classLeg := LegSpec{Kind: LegClassical, ParamSetID: uint8(ClassicalSchemeBLS12381)}
|
|
f.policy.required = []LegSpec{pqLeg, classLeg}
|
|
f.policy.allow[legModeParam{LegClassical, EvidenceClassicalAggregate, uint8(ClassicalSchemeBLS12381)}] = true
|
|
f.policy.classical[ClassicalSchemeBLS12381] = true
|
|
f.cert.RequiredLegsRoot = HashRequiredLegs(f.policy.required)
|
|
|
|
// Now the envelope message is determined; sign it with a real BLS key.
|
|
msg := consensusCertMessage(f.cert, HashRequiredLegs(f.policy.required))
|
|
aggPub, aggSig := realBLSAggregate(t, msg)
|
|
f.validators.classKeys[ClassicalSchemeBLS12381] = aggPub
|
|
|
|
innerWire, err := sc.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("marshal inner: %v", err)
|
|
}
|
|
f.cert.Evidence = []LegEvidence{
|
|
{Leg: pqLeg, Mode: EvidenceWeightedSigSet, Payload: innerWire},
|
|
{Leg: classLeg, Mode: EvidenceClassicalAggregate, Payload: encodeClassicalAggregatePayload(&ClassicalAggregatePayload{
|
|
Scheme: ClassicalSchemeBLS12381, Payload: aggSig,
|
|
})},
|
|
}
|
|
|
|
if err := VerifyConsensusCert(f.store, f.validators, f.cert); err != nil {
|
|
t.Fatalf("PQ + classical cert rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I6 — (kind, mode, param-set) must be policy-permitted.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestRejectsDisallowedParamSet: the cert's evidence names a param byte the
|
|
// policy does not permit for this (kind, mode). The verifier rejects before any
|
|
// signature math.
|
|
func TestRejectsDisallowedParamSet(t *testing.T) {
|
|
f := validFixture(t)
|
|
// The evidence claims ML-DSA-87 (0x43); the policy only permits 0x42.
|
|
f.cert.Evidence[0].Leg.ParamSetID = uint8(QuorumSchemeMLDSA87)
|
|
// The required-legs root is over the policy set (param 0x42), unchanged.
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrDisallowedEvidenceMode) {
|
|
t.Fatalf("disallowed param set err = %v, want ErrDisallowedEvidenceMode", err)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I7 — SLH-DSA may not be threshold-signed.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestRejectsSLHDSAThresholdSigMode: a Magnetar (SLH-DSA) leg selecting
|
|
// EvidenceThresholdSig is a hard reject. We require a Magnetar leg, allow the
|
|
// (Magnetar, ThresholdSig, param) triple in policy, and confirm the leg verifier
|
|
// refuses regardless.
|
|
func TestRejectsSLHDSAThresholdSigMode(t *testing.T) {
|
|
magLeg := LegSpec{Kind: LegMagnetarSLHDSA, ParamSetID: uint8(QuorumSchemeSLHDSA192s)}
|
|
policy := &envPolicy{
|
|
required: []LegSpec{magLeg},
|
|
allow: map[legModeParam]bool{
|
|
{LegMagnetarSLHDSA, EvidenceThresholdSig, uint8(QuorumSchemeSLHDSA192s)}: true,
|
|
},
|
|
thresholdWeight: 100,
|
|
classical: map[ClassicalScheme]bool{},
|
|
}
|
|
store := &envStore{policy: policy}
|
|
var vsetRoot [48]byte
|
|
for i := range vsetRoot {
|
|
vsetRoot[i] = 0x11
|
|
}
|
|
validators := &envValidators{root: vsetRoot, epoch: 1, classKeys: map[ClassicalScheme][]byte{}}
|
|
|
|
var bh [32]byte
|
|
cert := newCertForBH(policy, 1, 1, 1, 1, bh, vsetRoot, []LegEvidence{{
|
|
Leg: magLeg,
|
|
Mode: EvidenceThresholdSig,
|
|
Payload: encodeThresholdSigPayload(&ThresholdSigPayload{
|
|
Signature: []byte{0x01},
|
|
Accountability: &ThresholdAccountability{AggregateWeight: 100},
|
|
}),
|
|
}})
|
|
|
|
err := VerifyConsensusCert(store, validators, cert)
|
|
if !errors.Is(err, ErrSLHDSAThresholdSigForbidden) {
|
|
t.Fatalf("SLH-DSA threshold-sig err = %v, want ErrSLHDSAThresholdSigForbidden", err)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I8 — threshold-sig accountability.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestRejectsThresholdSigWithoutAccountability: a threshold-sig leg with no
|
|
// accountability binding is rejected — even before the signature is checked. A
|
|
// threshold sig that binds no signer set/weight is unacceptable.
|
|
func TestRejectsThresholdSigWithoutAccountability(t *testing.T) {
|
|
pulsarLeg := LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam}
|
|
policy := &envPolicy{
|
|
required: []LegSpec{pulsarLeg},
|
|
allow: map[legModeParam]bool{{LegPulsarMLDSA, EvidenceThresholdSig, pqParam}: true},
|
|
thresholdWeight: 100,
|
|
classical: map[ClassicalScheme]bool{},
|
|
}
|
|
store := &envStore{policy: policy}
|
|
var vsetRoot [48]byte
|
|
for i := range vsetRoot {
|
|
vsetRoot[i] = 0x22
|
|
}
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 1, classKeys: map[ClassicalScheme][]byte{},
|
|
pulsarKey: []byte("a-group-key"),
|
|
}
|
|
var bh [32]byte
|
|
cert := newCertForBH(policy, 1, 1, 1, 1, bh, vsetRoot, []LegEvidence{{
|
|
Leg: pulsarLeg,
|
|
Mode: EvidenceThresholdSig,
|
|
Payload: encodeThresholdSigPayload(&ThresholdSigPayload{
|
|
Signature: []byte{0x01, 0x02},
|
|
Accountability: nil, // <-- no accountability
|
|
}),
|
|
}})
|
|
|
|
err := VerifyConsensusCert(store, validators, cert)
|
|
if !errors.Is(err, ErrThresholdSigWithoutAccountability) {
|
|
t.Fatalf("threshold-sig without accountability err = %v, want ErrThresholdSigWithoutAccountability", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsThresholdSigSignerRootMismatch: accountability whose SignerRoot
|
|
// disagrees with the cert SignerRoot is rejected (the signer set the cert claims
|
|
// is not the one bound into the signed message).
|
|
func TestRejectsThresholdSigSignerRootMismatch(t *testing.T) {
|
|
pulsarLeg := LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam}
|
|
policy := &envPolicy{
|
|
required: []LegSpec{pulsarLeg},
|
|
allow: map[legModeParam]bool{{LegPulsarMLDSA, EvidenceThresholdSig, pqParam}: true},
|
|
thresholdWeight: 100,
|
|
classical: map[ClassicalScheme]bool{},
|
|
}
|
|
store := &envStore{policy: policy}
|
|
var vsetRoot [48]byte
|
|
for i := range vsetRoot {
|
|
vsetRoot[i] = 0x22
|
|
}
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 1, classKeys: map[ClassicalScheme][]byte{},
|
|
pulsarKey: []byte("a-group-key"),
|
|
}
|
|
var bh [32]byte
|
|
cert := newCertForBH(policy, 1, 1, 1, 1, bh, vsetRoot, nil)
|
|
var wrongRoot [32]byte // all zero, != cert.SignerRoot (0x5A..)
|
|
cert.Evidence = []LegEvidence{{
|
|
Leg: pulsarLeg,
|
|
Mode: EvidenceThresholdSig,
|
|
Payload: encodeThresholdSigPayload(&ThresholdSigPayload{
|
|
Signature: []byte{0x01, 0x02},
|
|
Accountability: &ThresholdAccountability{SignerRoot: wrongRoot, AggregateWeight: 100},
|
|
}),
|
|
}}
|
|
err := VerifyConsensusCert(store, validators, cert)
|
|
if !errors.Is(err, ErrSignerRootMismatch) {
|
|
t.Fatalf("signer-root mismatch err = %v, want ErrSignerRootMismatch", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsInsufficientWeight: accountability weight below the policy
|
|
// threshold weight is rejected.
|
|
func TestRejectsInsufficientWeight(t *testing.T) {
|
|
pulsarLeg := LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam}
|
|
policy := &envPolicy{
|
|
required: []LegSpec{pulsarLeg},
|
|
allow: map[legModeParam]bool{{LegPulsarMLDSA, EvidenceThresholdSig, pqParam}: true},
|
|
thresholdWeight: 100,
|
|
classical: map[ClassicalScheme]bool{},
|
|
}
|
|
store := &envStore{policy: policy}
|
|
var vsetRoot [48]byte
|
|
for i := range vsetRoot {
|
|
vsetRoot[i] = 0x22
|
|
}
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 1, classKeys: map[ClassicalScheme][]byte{},
|
|
pulsarKey: []byte("a-group-key"),
|
|
}
|
|
var bh [32]byte
|
|
cert := newCertForBH(policy, 1, 1, 1, 1, bh, vsetRoot, nil)
|
|
cert.Evidence = []LegEvidence{{
|
|
Leg: pulsarLeg,
|
|
Mode: EvidenceThresholdSig,
|
|
Payload: encodeThresholdSigPayload(&ThresholdSigPayload{
|
|
Signature: []byte{0x01, 0x02},
|
|
Accountability: &ThresholdAccountability{SignerRoot: cert.SignerRoot, AggregateWeight: 99}, // < 100
|
|
}),
|
|
}}
|
|
err := VerifyConsensusCert(store, validators, cert)
|
|
if !errors.Is(err, ErrInsufficientWeight) {
|
|
t.Fatalf("insufficient weight err = %v, want ErrInsufficientWeight", err)
|
|
}
|
|
}
|
|
|
|
// TestThresholdSigLegAcceptsRealCoronaAggregate is the POSITIVE threshold-sig
|
|
// path: a REAL Corona (Ring-LWE) threshold aggregate signature over the envelope
|
|
// message, with valid accountability, is ACCEPTED. This proves the threshold-sig
|
|
// leg wires origin's verifyCoronaLeg against a genuine group key + signature —
|
|
// the verification path is live, not faked. Heavy (Corona keygen) → skipped
|
|
// under -short, like the foundation's Polaris fixture.
|
|
func TestThresholdSigLegAcceptsRealCoronaAggregate(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping Corona DKG-bound threshold-sig fixture under -short")
|
|
}
|
|
const threshold, n = 2, 3
|
|
|
|
coronaLeg := LegSpec{Kind: LegCoronaLattice, ParamSetID: uint8(QuorumSchemeMLDSA65)}
|
|
policy := &envPolicy{
|
|
required: []LegSpec{coronaLeg},
|
|
allow: map[legModeParam]bool{{LegCoronaLattice, EvidenceThresholdSig, uint8(QuorumSchemeMLDSA65)}: true},
|
|
thresholdWeight: 100,
|
|
classical: map[ClassicalScheme]bool{},
|
|
}
|
|
store := &envStore{policy: policy}
|
|
|
|
var vsetRoot [48]byte
|
|
for i := range vsetRoot {
|
|
vsetRoot[i] = 0x33
|
|
}
|
|
var bh [32]byte
|
|
for i := range bh {
|
|
bh[i] = 0xC0
|
|
}
|
|
// Build the cert FIRST (header fixed) so the envelope message is determined,
|
|
// then sign THAT message with the real Corona committee.
|
|
cert := newCertForBH(policy, 9, 9, 9, 9, bh, vsetRoot, nil)
|
|
msg := consensusCertMessage(cert, HashRequiredLegs(policy.RequiredLegs()))
|
|
|
|
// Real Corona threshold signing over the envelope message.
|
|
shares, groupKey, err := coronaThreshold.GenerateKeys(threshold, n, nil)
|
|
if err != nil {
|
|
t.Fatalf("corona GenerateKeys: %v", err)
|
|
}
|
|
signers := make([]*coronaThreshold.Signer, threshold)
|
|
ids := make([]int, threshold)
|
|
for i := 0; i < threshold; i++ {
|
|
signers[i] = coronaThreshold.NewSigner(shares[i])
|
|
ids[i] = shares[i].Index
|
|
}
|
|
const sessionID = 27182
|
|
prf := []byte("consensus-cert-corona-prf-32-byte")
|
|
r1 := make(map[int]*coronaThreshold.Round1Data, threshold)
|
|
for i := 0; i < threshold; i++ {
|
|
rr1, rerr := signers[i].Round1(sessionID, prf, ids)
|
|
if rerr != nil {
|
|
t.Fatalf("corona Round1[%d]: %v", i, rerr)
|
|
}
|
|
r1[ids[i]] = rr1
|
|
}
|
|
r2 := make(map[int]*coronaThreshold.Round2Data, threshold)
|
|
for i := 0; i < threshold; i++ {
|
|
rr2, rerr := signers[i].Round2(sessionID, string(msg), prf, ids, r1)
|
|
if rerr != nil {
|
|
t.Fatalf("corona Round2[%d]: %v", i, rerr)
|
|
}
|
|
r2[ids[i]] = rr2
|
|
}
|
|
coronaSig, err := signers[0].Finalize(r2)
|
|
if err != nil {
|
|
t.Fatalf("corona Finalize: %v", err)
|
|
}
|
|
sigBytes := EncodeCoronaSig(coronaSig)
|
|
if len(sigBytes) == 0 {
|
|
t.Fatal("corona signature encoded to empty bytes")
|
|
}
|
|
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 9, classKeys: map[ClassicalScheme][]byte{},
|
|
coronaKey: groupKey,
|
|
}
|
|
cert.Evidence = []LegEvidence{{
|
|
Leg: coronaLeg,
|
|
Mode: EvidenceThresholdSig,
|
|
Payload: encodeThresholdSigPayload(&ThresholdSigPayload{
|
|
Signature: sigBytes,
|
|
Accountability: &ThresholdAccountability{SignerRoot: cert.SignerRoot, AggregateWeight: 150},
|
|
}),
|
|
}}
|
|
|
|
if err := VerifyConsensusCert(store, validators, cert); err != nil {
|
|
t.Fatalf("real Corona threshold-sig leg rejected: %v", err)
|
|
}
|
|
|
|
// Negative cross-check: a one-byte flip of the threshold signature makes the
|
|
// leg fail at the signature-verification clause (proves the verify is live).
|
|
bad := append([]byte(nil), sigBytes...)
|
|
bad[len(bad)/2] ^= 0xFF
|
|
cert.Evidence[0].Payload = encodeThresholdSigPayload(&ThresholdSigPayload{
|
|
Signature: bad,
|
|
Accountability: &ThresholdAccountability{SignerRoot: cert.SignerRoot, AggregateWeight: 150},
|
|
})
|
|
if err := VerifyConsensusCert(store, validators, cert); !errors.Is(err, ErrThresholdSigInvalid) {
|
|
t.Fatalf("corrupted Corona sig err = %v, want ErrThresholdSigInvalid", err)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I3 — validator set is verifier-pinned.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestRejectsWrongValidatorSetRoot: a cert whose ValidatorSetRoot disagrees with
|
|
// the committed set's Root() is rejected.
|
|
func TestRejectsWrongValidatorSetRoot(t *testing.T) {
|
|
f := validFixture(t)
|
|
f.cert.ValidatorSetRoot[0] ^= 0xFF
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrValidatorSetRootMismatch) {
|
|
t.Fatalf("wrong validator_set_root err = %v, want ErrValidatorSetRootMismatch", err)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I4 — domain message binds the full tuple (replay / mutation closed).
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestRejectsCrossEpochReplay: the same cert presented under a different epoch
|
|
// fails — the store would return a policy for the claimed epoch and the message
|
|
// binds epoch, so the inner cert's position cross-check fails.
|
|
func TestRejectsCrossEpochReplay(t *testing.T) {
|
|
f := validFixture(t)
|
|
f.cert.Epoch++ // replay at a different epoch
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
// The inner weighted-sig-set cert was built at the original epoch; the
|
|
// envelope's position cross-check (inner.Epoch != cert.Epoch) rejects.
|
|
if !errors.Is(err, ErrEvidenceWireCorrupt) {
|
|
t.Fatalf("cross-epoch replay err = %v, want ErrEvidenceWireCorrupt (position mismatch)", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsCrossChainReplay: the same cert presented under a different chain
|
|
// id is rejected (position cross-check on the inner cert).
|
|
func TestRejectsCrossChainReplay(t *testing.T) {
|
|
f := validFixture(t)
|
|
f.cert.ChainID++
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrEvidenceWireCorrupt) {
|
|
t.Fatalf("cross-chain replay err = %v, want ErrEvidenceWireCorrupt (position mismatch)", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsBlockHashMutation: flipping the envelope block hash away from the
|
|
// inner cert's value hash is rejected (position cross-check).
|
|
func TestRejectsBlockHashMutation(t *testing.T) {
|
|
f := validFixture(t)
|
|
f.cert.BlockHash[0] ^= 0xFF
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrEvidenceWireCorrupt) {
|
|
t.Fatalf("block-hash mutation err = %v, want ErrEvidenceWireCorrupt (position mismatch)", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsSignerRootMutation: a threshold-sig cert whose SignerRoot is
|
|
// mutated (so the accountability echo no longer matches) is rejected.
|
|
func TestRejectsSignerRootMutation(t *testing.T) {
|
|
pulsarLeg := LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam}
|
|
policy := &envPolicy{
|
|
required: []LegSpec{pulsarLeg},
|
|
allow: map[legModeParam]bool{{LegPulsarMLDSA, EvidenceThresholdSig, pqParam}: true},
|
|
thresholdWeight: 100,
|
|
classical: map[ClassicalScheme]bool{},
|
|
}
|
|
store := &envStore{policy: policy}
|
|
var vsetRoot [48]byte
|
|
for i := range vsetRoot {
|
|
vsetRoot[i] = 0x22
|
|
}
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 1, classKeys: map[ClassicalScheme][]byte{},
|
|
pulsarKey: []byte("a-group-key"),
|
|
}
|
|
var bh [32]byte
|
|
cert := newCertForBH(policy, 1, 1, 1, 1, bh, vsetRoot, nil)
|
|
cert.Evidence = []LegEvidence{{
|
|
Leg: pulsarLeg,
|
|
Mode: EvidenceThresholdSig,
|
|
Payload: encodeThresholdSigPayload(&ThresholdSigPayload{
|
|
Signature: []byte{0x01, 0x02},
|
|
Accountability: &ThresholdAccountability{SignerRoot: cert.SignerRoot, AggregateWeight: 100},
|
|
}),
|
|
}}
|
|
// Mutate the cert's SignerRoot AFTER the accountability echo was fixed to the
|
|
// original value → the SignerRoot != accountability.SignerRoot mismatch fires.
|
|
cert.SignerRoot[0] ^= 0xFF
|
|
err := VerifyConsensusCert(store, validators, cert)
|
|
if !errors.Is(err, ErrSignerRootMismatch) {
|
|
t.Fatalf("signer-root mutation err = %v, want ErrSignerRootMismatch", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsPolicyIDMutation: changing the cert's PolicyID changes the domain
|
|
// message; for the weighted-sig-set leg the inner cert is unaffected by PolicyID
|
|
// (it binds its own round digest), so this test asserts the envelope message
|
|
// itself is a function of PolicyID — a separate cert whose PolicyID differs
|
|
// produces a different envelope message (cross-domain non-transferability).
|
|
func TestRejectsPolicyIDMutation(t *testing.T) {
|
|
f := validFixture(t)
|
|
msg1 := consensusCertMessage(f.cert, f.cert.RequiredLegsRoot)
|
|
mutated := *f.cert
|
|
mutated.PolicyID++
|
|
msg2 := consensusCertMessage(&mutated, mutated.RequiredLegsRoot)
|
|
if bytes.Equal(msg1, msg2) {
|
|
t.Fatal("policy_id is not bound into the envelope message (msg unchanged after mutation)")
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I12 + foundation reuse — signer-set integrity at the envelope level.
|
|
//
|
|
// The foundation's weighted-sig-set predicate already enforces duplicate /
|
|
// unsorted / out-of-set / weight-overflow rejection (quorum_cert_test.go,
|
|
// quorum_cto_attacks_test.go). At the ENVELOPE level a corrupted inner cert
|
|
// surfaces as the foundation's typed error verbatim. These tests assert each
|
|
// such invariant is reachable and rejected THROUGH the envelope.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// mutateInner re-decodes the fixture's inner cert, applies a mutation, re-encodes
|
|
// it as the evidence payload, and returns the fixture ready to verify.
|
|
func mutateInner(t *testing.T, f *envFixture, mut func(*WeightedQuorumCert)) {
|
|
t.Helper()
|
|
inner := scenarioFromFixture(t, f)
|
|
mut(inner)
|
|
wire, err := inner.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("re-marshal mutated inner: %v", err)
|
|
}
|
|
for i := range f.cert.Evidence {
|
|
if f.cert.Evidence[i].Mode == EvidenceWeightedSigSet {
|
|
f.cert.Evidence[i].Payload = wire
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRejectsDuplicateValidatorID: a duplicate signer in the inner weighted-sig-
|
|
// set cert is rejected through the envelope (foundation clause).
|
|
func TestRejectsDuplicateValidatorID(t *testing.T) {
|
|
f := validFixture(t)
|
|
mutateInner(t, &f, func(c *WeightedQuorumCert) {
|
|
c.Signers[2] = c.Signers[1] // duplicate id adjacent after sort
|
|
c.SignerCommitment = c.computeSignerCommitment()
|
|
})
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrQCNotStrictlyIncreasing) {
|
|
t.Fatalf("duplicate validator id err = %v, want ErrQCNotStrictlyIncreasing", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsUnsortedValidatorIDs: unsorted signers in the inner cert are
|
|
// rejected through the envelope.
|
|
func TestRejectsUnsortedValidatorIDs(t *testing.T) {
|
|
f := validFixture(t)
|
|
mutateInner(t, &f, func(c *WeightedQuorumCert) {
|
|
c.Signers[0], c.Signers[1] = c.Signers[1], c.Signers[0]
|
|
c.SignerCommitment = c.computeSignerCommitment()
|
|
})
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrQCNotStrictlyIncreasing) {
|
|
t.Fatalf("unsorted validator ids err = %v, want ErrQCNotStrictlyIncreasing", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsOutOfSetValidator: an out-of-set signer (bad Merkle path) in the
|
|
// inner cert is rejected through the envelope.
|
|
func TestRejectsOutOfSetValidator(t *testing.T) {
|
|
f := validFixture(t)
|
|
mutateInner(t, &f, func(c *WeightedQuorumCert) {
|
|
c.Signers[1].VotingWeight = 999 // leaf no longer matches the committed set
|
|
c.AggregateWeight = c.AggregateWeight - 25 + 999
|
|
c.SignerCommitment = c.computeSignerCommitment()
|
|
})
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrQCMerkleInclusion) {
|
|
t.Fatalf("out-of-set validator err = %v, want ErrQCMerkleInclusion", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsWeightOverflow: a weight-overflowing inner cert is rejected through
|
|
// the envelope. Built directly (not via the prover, which guards overflow at
|
|
// assembly) so the VERIFIER's guard is what trips.
|
|
func TestRejectsWeightOverflow(t *testing.T) {
|
|
f := overflowFixture(t)
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrQCWeightOverflow) {
|
|
t.Fatalf("weight overflow err = %v, want ErrQCWeightOverflow", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsAggregateWeightMismatch: an inner cert whose claimed AggregateWeight
|
|
// disagrees with Σ signer weight is rejected through the envelope.
|
|
func TestRejectsAggregateWeightMismatch(t *testing.T) {
|
|
f := validFixture(t)
|
|
mutateInner(t, &f, func(c *WeightedQuorumCert) {
|
|
c.AggregateWeight++ // claim one more than Σ weight
|
|
c.SignerCommitment = c.computeSignerCommitment()
|
|
})
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrQCAggregateWeight) {
|
|
t.Fatalf("aggregate weight mismatch err = %v, want ErrQCAggregateWeight", err)
|
|
}
|
|
}
|
|
|
|
// TestRejectsDuplicateEvidenceForSameKind: a cert carrying TWO evidence entries
|
|
// for the same leg kind is rejected up front — evidence is one-to-one with kind,
|
|
// so a forged second entry can never shadow the first.
|
|
func TestRejectsDuplicateEvidenceForSameKind(t *testing.T) {
|
|
f := validFixture(t)
|
|
// Append a second (bogus) evidence entry for the SAME Pulsar kind.
|
|
f.cert.Evidence = append(f.cert.Evidence, LegEvidence{
|
|
Leg: LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam},
|
|
Mode: EvidenceWeightedSigSet,
|
|
Payload: []byte{0x00, 0x00},
|
|
})
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrDuplicateLegKind) {
|
|
t.Fatalf("duplicate evidence err = %v, want ErrDuplicateLegKind", err)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I9 — STARK proves the SAME predicate; audit-gated.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestStarkCompressedAndWeightedSigSetSamePredicate pins the predicate identity:
|
|
// the stark-compressed mode's public statement equals the WeightedSigSet
|
|
// predicate's bound inputs for the SAME cert, AND the backend fails closed until
|
|
// audit-gated (it never silently accepts a different/"close-enough" verifier).
|
|
func TestStarkCompressedAndWeightedSigSetSamePredicate(t *testing.T) {
|
|
f := validFixture(t)
|
|
msg := consensusCertMessage(f.cert, f.cert.RequiredLegsRoot)
|
|
|
|
// The canonical WeightedSigSet statement for this cert (single source of
|
|
// truth shared by VerifyStarkCompressedSigSet's input check).
|
|
want := starkPublicStatement(f.cert, msg)
|
|
|
|
// A STARK payload that correctly commits to the SAME statement: the input
|
|
// check PASSES, but the backend is not audit-gated → fail closed.
|
|
pulsarLeg := LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam}
|
|
f.policy.allow[legModeParam{LegPulsarMLDSA, EvidenceStarkCompressedSigSet, pqParam}] = true
|
|
f.cert.Evidence = []LegEvidence{{
|
|
Leg: pulsarLeg,
|
|
Mode: EvidenceStarkCompressedSigSet,
|
|
Payload: encodeStarkCompressedPayload(&StarkCompressedPayload{
|
|
PublicInputs: want, // identical to the WeightedSigSet statement
|
|
Proof: []byte{0xAA, 0xBB},
|
|
}),
|
|
}}
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrStarkBackendNotAuditGated) {
|
|
t.Fatalf("audit-gate: stark mode err = %v, want ErrStarkBackendNotAuditGated", err)
|
|
}
|
|
|
|
// A STARK payload that commits to a DIFFERENT statement is rejected at the
|
|
// predicate-identity check (it would prove a different relation).
|
|
bogus := append([]byte(nil), want...)
|
|
bogus[0] ^= 0xFF
|
|
f.cert.Evidence[0].Payload = encodeStarkCompressedPayload(&StarkCompressedPayload{
|
|
PublicInputs: bogus,
|
|
Proof: []byte{0xAA, 0xBB},
|
|
})
|
|
err = VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if !errors.Is(err, ErrStarkPublicInputsMismatch) {
|
|
t.Fatalf("predicate identity: stark mode err = %v, want ErrStarkPublicInputsMismatch", err)
|
|
}
|
|
|
|
// Direct assertion of the identity: the statement function is deterministic
|
|
// and equals itself for the same cert+message (the WeightedSigSet predicate
|
|
// inputs ARE the STARK public statement — pinned here so a future divergence
|
|
// trips CI).
|
|
if !bytes.Equal(starkPublicStatement(f.cert, msg), want) {
|
|
t.Fatal("stark public statement is not a deterministic function of the cert+message")
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// I13 — malformed evidence bytes: typed error, never a panic. Table + fuzz.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// TestConsensusCert_MalformedEvidenceNeverPanics drives hostile evidence bytes
|
|
// through every leg decoder and asserts a typed error and NO panic.
|
|
func TestConsensusCert_MalformedEvidenceNeverPanics(t *testing.T) {
|
|
cases := map[string][]byte{
|
|
"nil": nil,
|
|
"empty": {},
|
|
"single": {0x00},
|
|
"all_ff_short": bytesRepeat(0xFF, 8),
|
|
"all_ff_long": bytesRepeat(0xFF, 256),
|
|
"len_overrun": {0xFF, 0xFF, 0xFF, 0xFF}, // a 4GB length prefix over 0 bytes
|
|
"trailing_byte": {0x00, 0x00, 0x00, 0x00, 0x00, 0xDE},
|
|
}
|
|
for name, data := range cases {
|
|
t.Run("threshold/"+name, func(t *testing.T) {
|
|
if _, err := decodeThresholdSigPayload(data); err == nil && len(data) != 0 {
|
|
// only the exact valid frame may decode; these are all malformed
|
|
t.Fatalf("threshold decode accepted malformed %q", name)
|
|
}
|
|
})
|
|
t.Run("stark/"+name, func(t *testing.T) {
|
|
_, _ = decodeStarkCompressedPayload(data) // must not panic
|
|
})
|
|
t.Run("classical/"+name, func(t *testing.T) {
|
|
_, _ = decodeClassicalAggregatePayload(data) // must not panic
|
|
})
|
|
}
|
|
|
|
// Also drive a full envelope whose weighted-sig-set evidence is garbage: the
|
|
// envelope must surface a typed error, not panic.
|
|
f := validFixture(t)
|
|
f.cert.Evidence[0].Payload = bytesRepeat(0xFF, 32)
|
|
err := VerifyConsensusCert(f.store, f.validators, f.cert)
|
|
if err == nil {
|
|
t.Fatal("garbage weighted-sig-set evidence accepted")
|
|
}
|
|
if !errors.Is(err, ErrEvidenceWireCorrupt) {
|
|
t.Fatalf("garbage evidence err = %v, want ErrEvidenceWireCorrupt", err)
|
|
}
|
|
}
|
|
|
|
// FuzzVerifyConsensusCertEvidence asserts no leg decoder panics on arbitrary
|
|
// input and, when one decodes, the value re-encodes to bytes the decoder accepts
|
|
// again (idempotent on the accepted language).
|
|
func FuzzVerifyConsensusCertEvidence(f *testing.F) {
|
|
f.Add(encodeThresholdSigPayload(&ThresholdSigPayload{Signature: []byte{1, 2}, Accountability: &ThresholdAccountability{AggregateWeight: 9}}))
|
|
f.Add(encodeStarkCompressedPayload(&StarkCompressedPayload{PublicInputs: []byte{1}, Proof: []byte{2}}))
|
|
f.Add(encodeClassicalAggregatePayload(&ClassicalAggregatePayload{Scheme: ClassicalSchemeBLS12381, Payload: []byte{3}}))
|
|
f.Add([]byte{})
|
|
f.Add([]byte{0xFF, 0xFF, 0xFF, 0xFF})
|
|
|
|
f.Fuzz(func(t *testing.T, data []byte) {
|
|
// Each decoder must never panic. Where it accepts, re-encode∘decode must
|
|
// be stable.
|
|
if p, err := decodeThresholdSigPayload(data); err == nil {
|
|
if !bytes.Equal(encodeThresholdSigPayload(p), data) {
|
|
t.Fatal("threshold payload not canonical on accept")
|
|
}
|
|
}
|
|
if p, err := decodeStarkCompressedPayload(data); err == nil {
|
|
if !bytes.Equal(encodeStarkCompressedPayload(p), data) {
|
|
t.Fatal("stark payload not canonical on accept")
|
|
}
|
|
}
|
|
if p, err := decodeClassicalAggregatePayload(data); err == nil {
|
|
if !bytes.Equal(encodeClassicalAggregatePayload(p), data) {
|
|
t.Fatal("classical payload not canonical on accept")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Helpers specific to the envelope tests.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// overflowFixture builds a fixture whose inner weighted-sig-set cert overflows
|
|
// Σweight on the SECOND accumulation (first signer weight = MaxUint64). Built by
|
|
// hand (not the prover) so the VERIFIER guard is exercised through the envelope.
|
|
func overflowFixture(t *testing.T) envFixture {
|
|
t.Helper()
|
|
// Runtime vars so the wrapping sum is computed at runtime (a const maxU64+100
|
|
// is a compile-time overflow error).
|
|
var maxU64 uint64 = ^uint64(0)
|
|
var extra uint64 = 100
|
|
s1 := newMLDSASigner(t, 0x01, maxU64)
|
|
s2 := newMLDSASigner(t, 0x02, extra)
|
|
|
|
env := testEnv()
|
|
var vh [32]byte
|
|
for i := range vh {
|
|
vh[i] = 0xAB
|
|
}
|
|
env.ValueHash = vh
|
|
env.QuorumThreshold = 50
|
|
|
|
leaves := []WeightedValidatorLeaf{
|
|
{ValidatorID: s1.id, PublicKey: s1.pubBytes, VotingWeight: maxU64, ParameterSetID: uint8(s1.scheme), KeyVersion: s1.keyVer},
|
|
{ValidatorID: s2.id, PublicKey: s2.pubBytes, VotingWeight: extra, ParameterSetID: uint8(s2.scheme), KeyVersion: s2.keyVer},
|
|
}
|
|
set, err := BuildWeightedValidatorSet(env.Epoch, leaves)
|
|
if err != nil {
|
|
t.Fatalf("build set: %v", err)
|
|
}
|
|
env.ValidatorSetRoot = set.Root()
|
|
msg, err := QuorumConsensusMessage(env)
|
|
if err != nil {
|
|
t.Fatalf("message: %v", err)
|
|
}
|
|
sorted := set.Leaves()
|
|
idxOf := func(id [32]byte) int {
|
|
for i := range sorted {
|
|
if sorted[i].ValidatorID == id {
|
|
return i
|
|
}
|
|
}
|
|
t.Fatal("id not in set")
|
|
return -1
|
|
}
|
|
records := make([]QuorumSignerRecord, 0, 2)
|
|
for _, s := range []*testSigner{s1, s2} {
|
|
proof, perr := set.InclusionProof(idxOf(s.id))
|
|
if perr != nil {
|
|
t.Fatalf("proof: %v", perr)
|
|
}
|
|
records = append(records, QuorumSignerRecord{
|
|
ValidatorID: s.id,
|
|
PublicKey: s.pubBytes,
|
|
VotingWeight: s.weight,
|
|
Scheme: s.scheme,
|
|
ParamSetID: uint8(s.scheme),
|
|
KeyVersion: s.keyVer,
|
|
MerklePath: proof,
|
|
Signature: s.sign(t, msg, nil),
|
|
})
|
|
}
|
|
inner := &WeightedQuorumCert{
|
|
Version: QuorumCertVersion,
|
|
ChainID: env.ChainID,
|
|
Epoch: env.Epoch,
|
|
Height: env.Height,
|
|
Round: env.Round,
|
|
ValueHash: env.ValueHash,
|
|
QCType: env.QCType,
|
|
ValidatorSetRoot: set.Root(),
|
|
QuorumThreshold: env.QuorumThreshold,
|
|
AggregateWeight: maxU64 + extra, // wraps at runtime
|
|
SignerCount: 2,
|
|
Signers: records,
|
|
}
|
|
inner.SignerCommitment = inner.computeSignerCommitment()
|
|
innerWire, err := inner.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("marshal inner: %v", err)
|
|
}
|
|
|
|
requiredLeg := LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam}
|
|
policy := &envPolicy{
|
|
required: []LegSpec{requiredLeg},
|
|
allow: map[legModeParam]bool{{LegPulsarMLDSA, EvidenceWeightedSigSet, pqParam}: true},
|
|
thresholdWeight: 50,
|
|
classical: map[ClassicalScheme]bool{},
|
|
}
|
|
validators := &envValidators{
|
|
root: set.Root(),
|
|
epoch: env.Epoch,
|
|
cfg: QuorumVerifierConfig{Context: nil, MinThreshold: 50},
|
|
env: env,
|
|
classKeys: map[ClassicalScheme][]byte{},
|
|
}
|
|
cert := newCertForBH(policy, env.ChainID, env.Epoch, env.Height, env.Round, env.ValueHash, set.Root(),
|
|
[]LegEvidence{{Leg: requiredLeg, Mode: EvidenceWeightedSigSet, Payload: innerWire}})
|
|
return envFixture{store: &envStore{policy: policy}, validators: validators, policy: policy, cert: cert}
|
|
}
|
|
|
|
// realBLSAggregate produces a real BLS-12-381 aggregate public key + signature
|
|
// over msg from a small committee, so the classical leg verifies a genuine
|
|
// aggregate (not a stub). Same-message aggregation: one Verify(aggPub, aggSig,
|
|
// msg) accepts iff every committee member signed msg — exactly what
|
|
// verifyBLSLeg checks.
|
|
func realBLSAggregate(t *testing.T, msg []byte) (aggPub, aggSig []byte) {
|
|
t.Helper()
|
|
const n = 3
|
|
pubs := make([]*bls.PublicKey, 0, n)
|
|
sigs := make([]*bls.Signature, 0, n)
|
|
for i := 0; i < n; i++ {
|
|
sk, err := bls.NewSecretKey()
|
|
if err != nil {
|
|
t.Fatalf("bls keygen: %v", err)
|
|
}
|
|
sig, err := sk.Sign(msg)
|
|
if err != nil {
|
|
t.Fatalf("bls sign: %v", err)
|
|
}
|
|
pubs = append(pubs, sk.PublicKey())
|
|
sigs = append(sigs, sig)
|
|
}
|
|
apk, err := bls.AggregatePublicKeys(pubs)
|
|
if err != nil {
|
|
t.Fatalf("aggregate pubkeys: %v", err)
|
|
}
|
|
asig, err := bls.AggregateSignatures(sigs)
|
|
if err != nil {
|
|
t.Fatalf("aggregate sigs: %v", err)
|
|
}
|
|
return bls.PublicKeyToCompressedBytes(apk), bls.SignatureToBytes(asig)
|
|
}
|