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.
181 lines
7.4 KiB
Go
181 lines
7.4 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// quasar_finality.go — the ONE canonical Quasar finality message M.
|
|
//
|
|
// EVERY evidence lane in a Quasar cert (Beam/BLS, Pulsar/ML-DSA,
|
|
// Corona/Ringtail, P3Q rollup) signs the SAME M. M binds the full
|
|
// consensus position so a signature for one (chain, height, round, block,
|
|
// state, signer-set, key-era, policy) tuple can never be replayed for a
|
|
// different one (cross-domain non-transferability).
|
|
//
|
|
// Owner spec (the canonical tuple M MUST bind):
|
|
//
|
|
// M = H( "QUASAR_FINALITY_V1"
|
|
// ‖ chainID ‖ height ‖ round ‖ blockID ‖ stateRoot
|
|
// ‖ signerSetID ‖ keyEraID ‖ evidencePolicyID )
|
|
//
|
|
// RECONCILIATION with the existing envelope domain message
|
|
// (consensus_cert.go). The envelope's consensusCertMessage already binds a
|
|
// SUPERSET of the spec tuple and is the message the four dispatched
|
|
// evidence verifiers check; it is wire-stable. Rather than fork a second
|
|
// finality message (which would violate "all lanes sign the SAME M"), this
|
|
// file owns the SINGLE message core, finalityMessage, and BOTH entry points
|
|
// delegate to it:
|
|
//
|
|
// - consensusCertMessage(cert, requiredLegsRoot) — the envelope's
|
|
// accessor, extracts the tuple from a *ConsensusCert (unchanged callers).
|
|
// - QuasarFinalityMessage(params) — the owner-named typed
|
|
// constructor, builds the tuple from explicit finality values.
|
|
//
|
|
// The spec's field names map onto the envelope tuple as values, not places:
|
|
// signerSetID is the committed validator-set root; evidencePolicyID is the
|
|
// policy id; blockID is the block hash. The byte-level domain tag remains
|
|
// the wire-stable "Lux/ConsensusCert/v1" constant — "QUASAR_FINALITY_V1" is
|
|
// the canonical NAME of this message (the value), documented here.
|
|
//
|
|
// Decomplected: this file owns ONLY message construction. It decides nothing
|
|
// about which legs are required (policy), whether a leg verifies (the leg
|
|
// verifiers), or which era key to use (the KeyEra registry).
|
|
package quasar
|
|
|
|
import "encoding/binary"
|
|
|
|
// QuasarFinalityDomainName is the canonical name of the Quasar finality
|
|
// message per the owner spec. It documents the value; the wire-stable
|
|
// domain-separation bytes are consensusCertDomainTag /
|
|
// consensusCertMessageCustomization (consensus_cert.go) so the envelope's
|
|
// existing four leg verifiers continue to verify byte-for-byte.
|
|
const QuasarFinalityDomainName = "QUASAR_FINALITY_V1"
|
|
|
|
// finalityTuple is the full set of consensus-position values bound into the
|
|
// canonical finality message M. It is a pure value — the single argument to
|
|
// the one message builder.
|
|
//
|
|
// Field → owner-spec mapping:
|
|
//
|
|
// ChainID → chainID
|
|
// Height → height
|
|
// Round → round
|
|
// BlockHash → blockID
|
|
// StateRoot → stateRoot
|
|
// ValidatorSetRoot → signerSetID (the P-Chain-pinned committed signer set)
|
|
// PolicyID → evidencePolicyID
|
|
// KeyEraID → keyEraID (the KeyEra this cert finalises under)
|
|
//
|
|
// Version, Profile, Epoch and RequiredLegsRoot are additional bindings the
|
|
// envelope already commits (defence in depth — more binding is strictly more
|
|
// secure); the spec tuple is a subset of what M binds.
|
|
type finalityTuple struct {
|
|
Version uint16
|
|
Profile uint8
|
|
ChainID uint32
|
|
Epoch uint64
|
|
Height uint64
|
|
Round uint32
|
|
BlockHash [32]byte
|
|
StateRoot [32]byte
|
|
ValidatorSetRoot [48]byte
|
|
PolicyID uint32
|
|
RequiredLegsRoot [32]byte
|
|
SignerRoot [32]byte
|
|
KeyEraID uint64
|
|
}
|
|
|
|
// finalityMessage is the ONE canonical finality-message builder. Both the
|
|
// envelope accessor (consensusCertMessage) and the typed constructor
|
|
// (QuasarFinalityMessage) delegate here, so there is exactly one M for a
|
|
// given tuple and all lanes provably sign the same bytes.
|
|
//
|
|
// Layout (TupleHash256, so flipping any single field's bytes changes the
|
|
// digest even if a neighbour could absorb them — see round_digest.go):
|
|
//
|
|
// domain_tag ‖ version ‖ profile ‖ chain_id ‖ epoch ‖ height ‖ round ‖
|
|
// block_hash ‖ validator_set_root ‖ policy_id ‖ required_legs_root ‖
|
|
// signer_root ‖ state_root ‖ key_era_id
|
|
//
|
|
// The first twelve parts are byte-identical to the pre-existing envelope
|
|
// message; state_root and key_era_id are appended (the owner-spec extension).
|
|
func finalityMessage(t finalityTuple) []byte {
|
|
var u16 [2]byte
|
|
var u32 [4]byte
|
|
var u64 [8]byte
|
|
|
|
binary.BigEndian.PutUint16(u16[:], t.Version)
|
|
verBytes := append([]byte(nil), u16[:]...)
|
|
binary.BigEndian.PutUint32(u32[:], t.ChainID)
|
|
chainBytes := append([]byte(nil), u32[:]...)
|
|
binary.BigEndian.PutUint64(u64[:], t.Epoch)
|
|
epochBytes := append([]byte(nil), u64[:]...)
|
|
binary.BigEndian.PutUint64(u64[:], t.Height)
|
|
heightBytes := append([]byte(nil), u64[:]...)
|
|
binary.BigEndian.PutUint32(u32[:], t.Round)
|
|
roundBytes := append([]byte(nil), u32[:]...)
|
|
binary.BigEndian.PutUint32(u32[:], t.PolicyID)
|
|
policyBytes := append([]byte(nil), u32[:]...)
|
|
binary.BigEndian.PutUint64(u64[:], t.KeyEraID)
|
|
keyEraBytes := append([]byte(nil), u64[:]...)
|
|
|
|
parts := [][]byte{
|
|
[]byte(consensusCertDomainTag),
|
|
verBytes,
|
|
{t.Profile},
|
|
chainBytes,
|
|
epochBytes,
|
|
heightBytes,
|
|
roundBytes,
|
|
t.BlockHash[:],
|
|
t.ValidatorSetRoot[:],
|
|
policyBytes,
|
|
t.RequiredLegsRoot[:],
|
|
t.SignerRoot[:],
|
|
t.StateRoot[:],
|
|
keyEraBytes,
|
|
}
|
|
return tupleHash256RoundDigest(parts, 32, consensusCertMessageCustomization)
|
|
}
|
|
|
|
// QuasarFinalityParams carries the explicit finality values a caller signs
|
|
// over when producing a compact threshold cert outside the *ConsensusCert
|
|
// header path (e.g. a TALUS/Pulsar threshold ceremony, or a standalone
|
|
// finality attestation). It maps 1:1 to the owner-spec tuple; the additional
|
|
// envelope bindings (Version/Profile/Epoch/RequiredLegsRoot) default to the
|
|
// envelope's canonical values so QuasarFinalityMessage and the envelope's
|
|
// consensusCertMessage produce byte-identical M for the same cert.
|
|
type QuasarFinalityParams struct {
|
|
ChainID uint32
|
|
Epoch uint64
|
|
Height uint64
|
|
Round uint32
|
|
BlockID [32]byte // blockID
|
|
StateRoot [32]byte // stateRoot
|
|
SignerSetID [48]byte // signerSetID — committed validator-set root
|
|
KeyEraID uint64 // keyEraID
|
|
EvidencePolicyID uint32 // evidencePolicyID
|
|
RequiredLegsRoot [32]byte // policy-derived required-leg commitment
|
|
SignerRoot [32]byte // commitment to the actual signer set in evidence
|
|
Profile uint8 // chain security profile (defaults consistent with the cert)
|
|
}
|
|
|
|
// QuasarFinalityMessage builds the ONE canonical finality message M from
|
|
// explicit finality values. It is byte-identical to the envelope's
|
|
// consensusCertMessage for the same (chain, height, round, block, state,
|
|
// signer-set, key-era, policy) tuple — there is exactly one M.
|
|
func QuasarFinalityMessage(p QuasarFinalityParams) []byte {
|
|
return finalityMessage(finalityTuple{
|
|
Version: consensusCertVersion,
|
|
Profile: p.Profile,
|
|
ChainID: p.ChainID,
|
|
Epoch: p.Epoch,
|
|
Height: p.Height,
|
|
Round: p.Round,
|
|
BlockHash: p.BlockID,
|
|
StateRoot: p.StateRoot,
|
|
ValidatorSetRoot: p.SignerSetID,
|
|
PolicyID: p.EvidencePolicyID,
|
|
RequiredLegsRoot: p.RequiredLegsRoot,
|
|
SignerRoot: p.SignerRoot,
|
|
KeyEraID: p.KeyEraID,
|
|
})
|
|
}
|