mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
The compact group-key era was named for a place (Pulsar) when it is a generic value serving every threshold lane, scheme-qualified. Drop the Pulsar prefix and generalise the verifier so ONE type + ONE entry point covers both the Pulsar (FIPS-204 ML-DSA) and Corona (Ring-LWE Ringtail) lanes. Pure decomplect: same behaviour, value-not-place naming. - Type: PulsarKeyEra -> KeyEra (file pulsar_key_era.go -> key_era.go) - Field: MLDSAPubKey []byte -> GroupPubKey []byte (scheme-interpreted via SchemeID; not ML-DSA-specific). The unrelated Validator.MLDSAPubKey (*mldsa.PublicKey, a per-validator identity key) is left untouched. - Registry: PulsarKeyEraRegistry -> KeyEraRegistry (lane-agnostic; holds Pulsar and Corona eras alike), NewPulsarKeyEraRegistry -> NewKeyEraRegistry. Generalised verifier dispatch: - VerifyThresholdLeg(ev, M, era) is the ONE compact-threshold verify entry point. It resolves the era's lane through the suite registry (era.eraSuite, Pulsar|Corona only) and routes the group-signature check to that lane's boring verifier: pulsarwire.VerifyBytes for Pulsar, coronaThreshold.Verify for Corona (via new verifyCoronaGroupKeyLeg, which decodes the era's wire-byte group key and reuses verifyCoronaLeg). Both the era scheme and the evidence suite are resolved through the registry and must name the SAME lane + param set, so no suite string can reach the wrong verifier (reuses LookupSuite/resolveSuiteForLane lane safety). - VerifyPulsar is retained as a thin Pulsar-suite wrapper that refuses any non-Pulsar era at the door, then defers to VerifyThresholdLeg. Tests: existing boring-verifier + suite-dispatch-safety (VerifyPulsar) and dualpq tests hold unchanged; KeyEra registry test now proves lane-agnostic acceptance (corona era registers, non-threshold Beam refused); new TestCompact_VerifyThresholdLeg_CoronaLane proves a corona-suite era verifies via the corona path through the same KeyEra/dispatch with a real multi-node Ring-LWE signature, and that a pulsar suite can never reach the corona verifier. GOWORK=off go build ./... green; go test ./protocol/quasar/ green.
321 lines
14 KiB
Go
321 lines
14 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// key_era.go — the KeyEra registry and the deliberately BORING compact-threshold
|
|
// verifier. ONE KeyEra type plus ONE verify entry point (VerifyThresholdLeg)
|
|
// serve EVERY threshold lane: Pulsar (FIPS-204 ML-DSA group signature) and
|
|
// Corona (Ring-LWE Ringtail threshold signature).
|
|
//
|
|
// THE COMPACT GROUP KEY. For a 1000+ validator committee a threshold lane is
|
|
// ONE signature under ONE group public key — not 1000 per-validator signatures.
|
|
// A KeyEra is the registry record that pins that single group key to one
|
|
// validator-set era: which chain, which signer set (P-Chain-pinned), which
|
|
// key-era id + generation, what scheme/parameter set, and the activation
|
|
// evidence. ONE group key verifies the whole committee's threshold signature.
|
|
//
|
|
// VALUE, NOT PLACE. KeyEra is a generic value qualified by its SchemeID, not a
|
|
// pulsar-specific place: the SAME type serves the Pulsar and Corona lanes. The
|
|
// SchemeID says HOW to interpret the group-key bytes (ML-DSA vs Ringtail); the
|
|
// key material itself is a scheme-agnostic []byte (GroupPubKey). One type for
|
|
// all lanes — not a Pulsar-specific era type plus a separate Corona-specific
|
|
// one; the lane is a value (the scheme), not the type.
|
|
//
|
|
// DELIBERATELY BORING VERIFIER. The chain does not care HOW the group signature
|
|
// was produced — TALUS MPC, an HSM ceremony, a TEE, or a single trusted signer.
|
|
// VerifyThresholdLeg verifies a NORMAL group-key signature under the era group
|
|
// key over the canonical finality message M. That is the whole point: the trust
|
|
// model of key PRODUCTION (talus-mpc / ceremony / tee / p3q-rollup fallback) is
|
|
// recorded in the era's KeygenMode for audit, but it is ORTHOGONAL to
|
|
// verification. The verifier stays in its lane: group sig, group key, M.
|
|
//
|
|
// ONE VERIFY PATH PER LANE. VerifyThresholdLeg resolves the era's lane through
|
|
// the suite registry and routes the signature check through the SAME stateless
|
|
// path the envelope's leg verifier uses — pulsarwire.VerifyBytes (FIPS-204) for
|
|
// Pulsar, coronaThreshold.Verify (Ring-LWE) for Corona — so the standalone and
|
|
// envelope verifies agree byte-for-byte. The suite registry's lane safety
|
|
// (LookupSuite / resolveSuiteForLane) guarantees no scheme string can dispatch
|
|
// a signature into the wrong lane's verifier.
|
|
package quasar
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
)
|
|
|
|
// KeyEra pins one compact threshold GROUP key to one validator-set era. It is a
|
|
// generic value serving every threshold lane; the SchemeID names the suite (and
|
|
// thus the lane + parameter set) the group key is interpreted under. Owner-spec
|
|
// value types are expressed in this package's native types (uint32 chain id,
|
|
// [48]byte committed signer-set root) so an era binds directly to a
|
|
// ConsensusCert header — values, not places.
|
|
type KeyEra struct {
|
|
// ChainID is the chain this era belongs to (matches ConsensusCert.ChainID).
|
|
ChainID uint32
|
|
|
|
// SignerSetID is the committed validator-set identity the group key was
|
|
// generated for — the P-Chain-pinned signer set. Matches a ConsensusCert's
|
|
// ValidatorSetRoot. A signature under this era's key attests THIS signer set.
|
|
SignerSetID [48]byte
|
|
|
|
// KeyEraID + Generation identify the era. KeyEraID advances on a signer-set
|
|
// rotation; Generation advances on a within-era key refresh/reshare. Both
|
|
// are bound into M and re-checked structurally by VerifyThresholdLeg.
|
|
KeyEraID uint64
|
|
Generation uint64
|
|
|
|
// PChainHeight is the P-Chain height the signer set was pinned at.
|
|
PChainHeight uint64
|
|
|
|
// GroupPubKey is the compact threshold GROUP public key, wire-framed as the
|
|
// era's lane verifier accepts it (pulsarwire.VerifyBytes for a Pulsar ML-DSA
|
|
// lane; coronaThreshold.GroupKey.UnmarshalBinary for a Corona Ringtail lane).
|
|
// It is SCHEME-INTERPRETED: SchemeID says how to read these bytes. ONE key
|
|
// for the whole committee.
|
|
GroupPubKey []byte
|
|
|
|
// Threshold is the minimum aggregate signer WEIGHT the era's quorum
|
|
// represents (e.g. > 2/3 stake). Informational at the era level; the
|
|
// envelope's policy.ThresholdWeight() is the enforced floor per cert.
|
|
Threshold uint64
|
|
|
|
// SchemeID is the wire-stable suite ID the era's signatures are produced
|
|
// under (e.g. "Lux-Pulsar-TALUS-MLDSA65", "Lux-Corona-Ringtail-L3-v1"). MUST
|
|
// resolve, via the suite registry, to a THRESHOLD group-key lane (Pulsar or
|
|
// Corona) — VerifyThresholdLeg enforces this so a mislabelled era can never
|
|
// accept a foreign-lane signature.
|
|
SchemeID string
|
|
|
|
// KeygenMode records how the group key was produced — for AUDIT only, never
|
|
// for verification: "talus-mpc" | "ceremony" | "tee" | "p3q-rollup-fallback".
|
|
KeygenMode string
|
|
|
|
// ActivationCert is the evidence that installed this era (e.g. the previous
|
|
// era's threshold signature over this era's key). Stored for audit; the
|
|
// activation-chain verifier (separate concern) checks it.
|
|
ActivationCert []byte
|
|
}
|
|
|
|
// eraSuite resolves the era's suite and asserts it is a THRESHOLD group-key lane
|
|
// — Pulsar ML-DSA or Corona Ringtail, the lanes a compact KeyEra can serve. Beam
|
|
// (classical aggregate) and P3Q (per-validator rollup) are NOT compact group-key
|
|
// threshold lanes and are refused here, so a KeyEra can never be registered or
|
|
// verified against a non-threshold scheme.
|
|
func (e KeyEra) eraSuite() (Suite, error) {
|
|
s, ok := LookupSuite(e.SchemeID)
|
|
if !ok {
|
|
return Suite{}, fmt.Errorf("%w: %q", ErrUnknownSuite, e.SchemeID)
|
|
}
|
|
switch s.Kind {
|
|
case EvidencePulsarThresholdMLDSA, EvidenceCoronaRingtail:
|
|
return s, nil
|
|
default:
|
|
return Suite{}, fmt.Errorf("%w: suite %q is %q, not a threshold group-key lane",
|
|
ErrSuiteLaneMismatch, e.SchemeID, s.Kind)
|
|
}
|
|
}
|
|
|
|
// PulsarEvidence is the compact threshold-lane evidence: ONE threshold signature
|
|
// plus the era coordinates it was produced under. It carries NO per-validator
|
|
// signatures — its size is O(1) in committee size. The SAME shape carries any
|
|
// threshold lane's signature; the era's SchemeID (matched below) selects the
|
|
// lane that interprets it.
|
|
type PulsarEvidence struct {
|
|
// SuiteID names the concrete scheme (must equal the era's SchemeID and
|
|
// resolve to the era's threshold lane).
|
|
SuiteID string
|
|
|
|
// KeyEraID + Generation must match the era being verified against.
|
|
KeyEraID uint64
|
|
Generation uint64
|
|
|
|
// SignerSetID is the committed signer set the evidence claims; must match
|
|
// the era's SignerSetID.
|
|
SignerSetID [48]byte
|
|
|
|
// Signature is the threshold GROUP signature bytes over M — a FIPS-204 ML-DSA
|
|
// signature on the Pulsar lane, a Ringtail signature on the Corona lane.
|
|
Signature []byte
|
|
}
|
|
|
|
// Typed errors for the boring verifier. Each names exactly one rejected clause.
|
|
var (
|
|
// ErrSuiteMismatch — the evidence's suite id is not the era's scheme id.
|
|
ErrSuiteMismatch = errors.New("quasar: threshold evidence suite id does not match the key-era scheme id")
|
|
|
|
// ErrWrongEra — the evidence's era coordinates (key-era id / generation /
|
|
// signer set) do not match the era being verified against.
|
|
ErrWrongEra = errors.New("quasar: threshold evidence era coordinates do not match the key era")
|
|
|
|
// ErrBadSignature — the threshold group signature failed verification under
|
|
// the era group key over M.
|
|
ErrBadSignature = errors.New("quasar: threshold group signature failed verification")
|
|
|
|
// ErrEraKeyEmpty — the era carries no group key (mis-provisioned era).
|
|
ErrEraKeyEmpty = errors.New("quasar: key era has no group public key")
|
|
)
|
|
|
|
// VerifyThresholdLeg is the ONE compact-threshold verify entry point. It serves
|
|
// EVERY threshold lane: it resolves the era's scheme to its lane via the suite
|
|
// registry and routes to that lane's deliberately boring verifier — Pulsar
|
|
// (FIPS-204 ML-DSA group signature) or Corona (Ring-LWE Ringtail threshold
|
|
// signature) — under the era's compact GROUP key over the canonical finality
|
|
// message M. It is agnostic to how the signature was produced (TALUS MPC / HSM /
|
|
// TEE / committee); KeygenMode records that for audit only.
|
|
//
|
|
// Checks, in order (each a distinct typed reject):
|
|
//
|
|
// 1. The era's scheme resolves (via the suite registry) to a THRESHOLD lane —
|
|
// Pulsar or Corona; a non-threshold or unknown scheme is a hard reject
|
|
// (ErrUnknownSuite / ErrSuiteLaneMismatch).
|
|
// 2. The evidence's own suite resolves to the SAME lane as the era — no suite
|
|
// string can route a foreign-lane signature here (ErrUnknownSuite /
|
|
// ErrSuiteLaneMismatch).
|
|
// 3. The evidence suite equals the era's declared scheme (ErrSuiteMismatch),
|
|
// and their parameter sets match (ErrSuiteParamMismatch).
|
|
// 4. Era coordinates match: KeyEraID, Generation, and SignerSetID
|
|
// (ErrWrongEra). These are also bound into M, so this is defence in depth.
|
|
// 5. The group signature verifies under era.GroupPubKey over M
|
|
// (ErrBadSignature), via the same stateless path the matching envelope leg
|
|
// uses — pulsarwire.VerifyBytes for Pulsar, coronaThreshold.Verify for
|
|
// Corona.
|
|
func VerifyThresholdLeg(ev PulsarEvidence, M []byte, era KeyEra) error {
|
|
// (1) the era's scheme must be a threshold group-key lane (Pulsar | Corona).
|
|
eraSuite, err := era.eraSuite()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// (2) the evidence's own suite must resolve to the SAME lane — a different
|
|
// lane (e.g. a Corona suite for a Pulsar era) is rejected before any
|
|
// signature math, so no suite string reaches the wrong verifier.
|
|
evSuite, err := resolveSuiteForLane(ev.SuiteID, eraSuite.Kind, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// (3) the evidence suite must be the era's declared scheme with the same
|
|
// parameter set (a mislabelled era is rejected here).
|
|
if ev.SuiteID != era.SchemeID {
|
|
return fmt.Errorf("%w: evidence %q era %q", ErrSuiteMismatch, ev.SuiteID, era.SchemeID)
|
|
}
|
|
if evSuite.ParamSet != eraSuite.ParamSet {
|
|
return fmt.Errorf("%w: evidence param 0x%02x era param 0x%02x",
|
|
ErrSuiteParamMismatch, evSuite.ParamSet, eraSuite.ParamSet)
|
|
}
|
|
|
|
// (4) era coordinates.
|
|
if ev.KeyEraID != era.KeyEraID || ev.Generation != era.Generation {
|
|
return fmt.Errorf("%w: evidence era (%d,%d) key era (%d,%d)",
|
|
ErrWrongEra, ev.KeyEraID, ev.Generation, era.KeyEraID, era.Generation)
|
|
}
|
|
if ev.SignerSetID != era.SignerSetID {
|
|
return fmt.Errorf("%w: signer-set id", ErrWrongEra)
|
|
}
|
|
|
|
// (5) the boring per-lane group-signature verify. Same stateless path the
|
|
// envelope's leg verifier uses, dispatched by the resolved lane.
|
|
if len(era.GroupPubKey) == 0 {
|
|
return ErrEraKeyEmpty
|
|
}
|
|
switch eraSuite.Kind {
|
|
case EvidencePulsarThresholdMLDSA:
|
|
// pulsarwire.VerifyBytes (FIPS-204, stateless) — same as verifyPulsarLeg.
|
|
if !verifyPulsarLeg(M, era.GroupPubKey, ev.Signature) {
|
|
return ErrBadSignature
|
|
}
|
|
case EvidenceCoronaRingtail:
|
|
// coronaThreshold.Verify (Ring-LWE) — same as verifyCoronaLeg, with the
|
|
// group key decoded from the era's wire bytes.
|
|
if !verifyCoronaGroupKeyLeg(M, era.GroupPubKey, ev.Signature) {
|
|
return ErrBadSignature
|
|
}
|
|
default:
|
|
// Unreachable: eraSuite() already constrained the lane to Pulsar|Corona.
|
|
return fmt.Errorf("%w: suite %q is %q", ErrSuiteLaneMismatch, era.SchemeID, eraSuite.Kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VerifyPulsar is the thin Pulsar-suite wrapper over the one VerifyThresholdLeg
|
|
// entry point, retained for callers that name the lane explicitly. It refuses
|
|
// any non-Pulsar era up-front (so a Corona era can never enter via this door),
|
|
// then defers to VerifyThresholdLeg, which performs the boring FIPS-204 ML-DSA
|
|
// group-signature verify under the era's compact group key over M.
|
|
func VerifyPulsar(ev PulsarEvidence, M []byte, era KeyEra) error {
|
|
if _, err := resolveSuiteForLane(era.SchemeID, EvidencePulsarThresholdMLDSA, 0); err != nil {
|
|
return err
|
|
}
|
|
return VerifyThresholdLeg(ev, M, era)
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// KeyEraRegistry — resolve the compact group key for a (chain, signer set,
|
|
// key-era, generation). The verifier loads the era from here, never from the
|
|
// cert (the cert only names the KeyEraID it claims; the registry supplies the
|
|
// trusted key material). One registry holds eras for EVERY threshold lane.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// eraKey is the registry index for one era.
|
|
type eraKey struct {
|
|
chainID uint32
|
|
signerSetID [48]byte
|
|
keyEraID uint64
|
|
generation uint64
|
|
}
|
|
|
|
// KeyEraRegistry maps era coordinates to the trusted compact group key. Safe for
|
|
// concurrent reads after registration. Lane-agnostic: it holds Pulsar and Corona
|
|
// eras alike, each self-describing via its SchemeID.
|
|
type KeyEraRegistry struct {
|
|
mu sync.RWMutex
|
|
eras map[eraKey]KeyEra
|
|
}
|
|
|
|
// ErrEraNotFound — no era is registered for the requested coordinates.
|
|
var ErrEraNotFound = errors.New("quasar: no key era registered for these coordinates")
|
|
|
|
// ErrEraConflict — a DIFFERENT era is already registered for these exact
|
|
// coordinates. Era coordinates are immutable; re-registering a changed key
|
|
// under the same (chain, signer set, key-era, generation) is a hard reject.
|
|
var ErrEraConflict = errors.New("quasar: a different key era is already registered for these coordinates")
|
|
|
|
// NewKeyEraRegistry returns an empty registry.
|
|
func NewKeyEraRegistry() *KeyEraRegistry {
|
|
return &KeyEraRegistry{eras: make(map[eraKey]KeyEra)}
|
|
}
|
|
|
|
// Register installs an era. The era's SchemeID must resolve to a THRESHOLD lane
|
|
// (Pulsar or Corona) and it must carry a group key. Re-registering the SAME era
|
|
// (byte-equal key + scheme) is idempotent; registering a different key under the
|
|
// same coordinates is ErrEraConflict (eras are immutable).
|
|
func (r *KeyEraRegistry) Register(era KeyEra) error {
|
|
if _, err := era.eraSuite(); err != nil {
|
|
return err
|
|
}
|
|
if len(era.GroupPubKey) == 0 {
|
|
return ErrEraKeyEmpty
|
|
}
|
|
k := eraKey{chainID: era.ChainID, signerSetID: era.SignerSetID, keyEraID: era.KeyEraID, generation: era.Generation}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if existing, ok := r.eras[k]; ok {
|
|
if existing.SchemeID != era.SchemeID || !bytes.Equal(existing.GroupPubKey, era.GroupPubKey) {
|
|
return ErrEraConflict
|
|
}
|
|
return nil // idempotent
|
|
}
|
|
r.eras[k] = era
|
|
return nil
|
|
}
|
|
|
|
// Lookup resolves the era for the given coordinates.
|
|
func (r *KeyEraRegistry) Lookup(chainID uint32, signerSetID [48]byte, keyEraID, generation uint64) (KeyEra, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
era, ok := r.eras[eraKey{chainID: chainID, signerSetID: signerSetID, keyEraID: keyEraID, generation: generation}]
|
|
if !ok {
|
|
return KeyEra{}, ErrEraNotFound
|
|
}
|
|
return era, nil
|
|
}
|