mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
409 lines
17 KiB
Go
409 lines
17 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// consensus_cert_dualpq_test.go — the multi-node integration proof for
|
|
// PARALLEL post-quantum finality: every cert carries a Pulsar (Module-LWE /
|
|
// FIPS-204 ML-DSA) leg AND a Corona (Ring-LWE) leg, both required (AND-mode),
|
|
// both verified live against real group keys and real signatures.
|
|
//
|
|
// What these tests prove, end to end, with REAL crypto (no fakes, no stubs):
|
|
//
|
|
// 1. PARALLEL SIGNING. The Corona leg is produced by a genuine multi-node
|
|
// two-round threshold ceremony — N separate *coronaThreshold.Signer
|
|
// objects, each holding EXACTLY ONE key share, exchanging Round1/Round2
|
|
// messages over an explicit map "bus", aggregated by one designated node.
|
|
// The Pulsar leg is a real FIPS-204 ML-DSA-65 signature its on-chain
|
|
// verifier (verifyPulsarLeg → pulsarwire.VerifyBytes) accepts byte-for-byte.
|
|
//
|
|
// 2. AND-MODE. The dual-PQ ConsensusCert verifies ONLY when BOTH legs are
|
|
// present and BOTH verify. Dropping or corrupting EITHER leg is a hard
|
|
// reject — the policy's RequiredLegs() = {Pulsar, Corona} is conjunctive.
|
|
//
|
|
// 3. SUB-QUORUM FAILS, EITHER LEG. A Corona sub-quorum (t-1 signers) yields a
|
|
// signature that does not verify under the group key ⇒ finality fails.
|
|
// A missing/corrupt Pulsar leg ⇒ finality fails.
|
|
//
|
|
// 4. SINGLE-SHARE INVARIANT. No signing-path constructor takes a quorum of
|
|
// shares. The Corona signer constructor takes EXACTLY ONE *KeyShare
|
|
// (compile-time witness below); the aggregator's Finalize takes Round2
|
|
// DATA, never shares. The single-share property is structural, not
|
|
// incidental.
|
|
//
|
|
// HONEST SCOPE (see luxfi/pulsar BLOCKERS.md PULSAR-V12-PARALLEL-PQ): the Corona
|
|
// leg here is a true no-reconstruct t-of-n threshold (Ring-LWE shares are
|
|
// linear, so a dealerless Pedersen DKG + per-node signing never reconstructs
|
|
// the secret). The Pulsar leg's SIGNATURE is a real FIPS-204 ML-DSA group-key
|
|
// signature — its no-reconstruct t-of-n THRESHOLD production (gate-C's
|
|
// per-node algebraic signer) is blocked on a Pulsar release carrying a
|
|
// poly-vector secret-share type (ML-DSA's s1 is non-linear in the GF(257)
|
|
// seed-share, so the current KeyShare admits only the reconstruct path). The
|
|
// permissionless guarantee therefore comes from the Corona leg in AND-mode,
|
|
// exactly as dualpq.go documents. This file does NOT claim a single-share
|
|
// Pulsar threshold ceremony it cannot run.
|
|
package quasar
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
pulsarwire "github.com/luxfi/pulsar/pkg/pulsar"
|
|
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
|
|
)
|
|
|
|
// Compile-time single-share witness. The ONLY Corona signing constructor on
|
|
// the chain path takes EXACTLY ONE *KeyShare. If anyone ever adds a
|
|
// constructor that takes a slice of shares (a quorum in one process), this
|
|
// line keeps compiling but the structural tests below — and a source grep —
|
|
// catch it. The aggregator (Signer.Finalize) takes a map of Round2 DATA, not
|
|
// shares, so no process is ever one Lagrange combine from the key.
|
|
var _ func(*coronaThreshold.KeyShare) *coronaThreshold.Signer = coronaThreshold.NewSigner
|
|
|
|
const (
|
|
dualPQThreshold = 2 // t — the quorum that must sign
|
|
dualPQN = 3 // n — committee size
|
|
dualPQSession = 31415
|
|
)
|
|
|
|
// dualPQPrf is the per-session PRF key the Corona ceremony binds (32 bytes).
|
|
var dualPQPrf = []byte("consensus-dualpq-corona-prf-32by")
|
|
|
|
// dualPQPolicy returns the AND-mode dual-PQ policy: BOTH the Pulsar and Corona
|
|
// threshold-sig legs are required, both at ML-DSA-65. This mirrors a config
|
|
// CertPolicy{Mode: CertModeStrict, Variant: CertVariantStrict}, whose
|
|
// RequiredLegs() is {Pulsar, Corona}.
|
|
func dualPQPolicy() *envPolicy {
|
|
return &envPolicy{
|
|
required: DualPQRequiredLegs(pqParam),
|
|
allow: map[legModeParam]bool{
|
|
{LegPulsarMLDSA, EvidenceThresholdSig, pqParam}: true,
|
|
{LegCoronaLattice, EvidenceThresholdSig, pqParam}: true,
|
|
},
|
|
thresholdWeight: 100,
|
|
classical: map[ClassicalScheme]bool{},
|
|
}
|
|
}
|
|
|
|
// dualPQCertAndMessage builds a fixed dual-PQ cert header and the envelope
|
|
// message both legs sign. Header first, message determined, THEN sign — so the
|
|
// two threshold ceremonies sign exactly the bytes the verifier will recompute.
|
|
func dualPQCertAndMessage(policy *envPolicy) (*ConsensusCert, []byte, [48]byte) {
|
|
var vsetRoot [48]byte
|
|
for i := range vsetRoot {
|
|
vsetRoot[i] = 0x33
|
|
}
|
|
var bh [32]byte
|
|
for i := range bh {
|
|
bh[i] = 0xC0
|
|
}
|
|
cert := newCertForBH(policy, 9, 9, 9, 9, bh, vsetRoot, nil)
|
|
msg := consensusCertMessage(cert, HashRequiredLegs(policy.RequiredLegs()))
|
|
return cert, msg, vsetRoot
|
|
}
|
|
|
|
// coronaNode is one validator's LOCAL state for the Corona threshold ceremony:
|
|
// exactly one *Signer (built from exactly one share) and its Shamir index. The
|
|
// node never sees another node's share — only Round1/Round2 messages on the
|
|
// bus. This is the structural single-share boundary made explicit.
|
|
type coronaNode struct {
|
|
id int
|
|
signer *coronaThreshold.Signer
|
|
}
|
|
|
|
// signCoronaLegMultiNode runs a genuine multi-node Corona (Ring-LWE) threshold
|
|
// signature over msg with `participants` signers out of n, threshold t.
|
|
//
|
|
// Each node is a SEPARATE coronaNode holding ONE share; Round1 and Round2
|
|
// outputs are exchanged via explicit maps (the message bus); the designated
|
|
// aggregator (node 0) calls Finalize over the collected Round2 DATA. No node,
|
|
// and not the aggregator, ever holds a second node's share.
|
|
//
|
|
// With participants == t this yields a valid signature; with participants < t
|
|
// the produced signature does not verify under the group key (the sub-quorum
|
|
// proof). Returns (sig, groupKey, nodes, err) — nodes is returned so a caller
|
|
// can assert the per-node single-share structure.
|
|
func signCoronaLegMultiNode(t testing.TB, participants, thr, n int, msg []byte) (*coronaThreshold.Signature, *coronaThreshold.GroupKey, []coronaNode) {
|
|
t.Helper()
|
|
|
|
// Genesis keygen. GenerateKeys is the trusted-dealer fixture for the test
|
|
// vector; Corona's PRODUCTION genesis is the dealerless Pedersen DKG
|
|
// (corona/keyera), which never forms the master secret. Keygen is a
|
|
// genesis concern; the per-block CHAIN path below is what must be — and is
|
|
// — single-share.
|
|
shares, groupKey, err := coronaThreshold.GenerateKeys(thr, n, nil)
|
|
if err != nil {
|
|
t.Fatalf("corona GenerateKeys(t=%d,n=%d): %v", thr, n, err)
|
|
}
|
|
|
|
nodes := make([]coronaNode, participants)
|
|
ids := make([]int, participants)
|
|
for i := 0; i < participants; i++ {
|
|
// ONE share per node. coronaThreshold.NewSigner's signature
|
|
// (*KeyShare)→*Signer is the compile-time witness that this is a
|
|
// single-share constructor.
|
|
nodes[i] = coronaNode{id: shares[i].Index, signer: coronaThreshold.NewSigner(shares[i])}
|
|
ids[i] = shares[i].Index
|
|
}
|
|
|
|
// Round 1 — each node emits its Round1 message onto the bus.
|
|
bus1 := make(map[int]*coronaThreshold.Round1Data, participants)
|
|
for _, nd := range nodes {
|
|
r1, rerr := nd.signer.Round1(dualPQSession, dualPQPrf, ids)
|
|
if rerr != nil {
|
|
t.Fatalf("corona Round1[node %d]: %v", nd.id, rerr)
|
|
}
|
|
bus1[nd.id] = r1
|
|
}
|
|
|
|
// Round 2 — each node consumes the Round1 bus and emits its Round2 message.
|
|
bus2 := make(map[int]*coronaThreshold.Round2Data, participants)
|
|
for _, nd := range nodes {
|
|
r2, rerr := nd.signer.Round2(dualPQSession, string(msg), dualPQPrf, ids, bus1)
|
|
if rerr != nil {
|
|
t.Fatalf("corona Round2[node %d]: %v", nd.id, rerr)
|
|
}
|
|
bus2[nd.id] = r2
|
|
}
|
|
|
|
// Aggregate — the designated node combines Round2 DATA (not shares).
|
|
sig, err := nodes[0].signer.Finalize(bus2)
|
|
if err != nil {
|
|
t.Fatalf("corona Finalize: %v", err)
|
|
}
|
|
return sig, groupKey, nodes
|
|
}
|
|
|
|
// signPulsarLegFIPS204 produces a real FIPS-204 ML-DSA-65 signature over msg
|
|
// and the matching wire-framed group public key. verifyPulsarLeg →
|
|
// pulsarwire.VerifyBytes accepts this byte-for-byte (empty FIPS-204 context,
|
|
// matching the verifier). See the HONEST SCOPE note in the file header for why
|
|
// this is a group-key signature rather than a no-reconstruct t-of-n ceremony.
|
|
func signPulsarLegFIPS204(t testing.TB, msg []byte) (sigWire, gkWire []byte) {
|
|
t.Helper()
|
|
params := pulsarwire.MustParamsFor(pulsarwire.ModeP65)
|
|
var seed [pulsarwire.SeedSize]byte
|
|
copy(seed[:], "lux-dualpq-pulsar-leg-genkey!!!!")
|
|
sk, err := pulsarwire.KeyFromSeed(params, seed)
|
|
if err != nil {
|
|
t.Fatalf("pulsar KeyFromSeed: %v", err)
|
|
}
|
|
// ctx=nil, randomized=false ⇒ deterministic, matching VerifyBytes' empty
|
|
// context. The signature verifies under an unmodified FIPS-204 verifier.
|
|
sig, err := pulsarwire.Sign(params, sk, msg, nil, false, nil)
|
|
if err != nil {
|
|
t.Fatalf("pulsar Sign: %v", err)
|
|
}
|
|
gkWire, err = sk.Pub.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("pulsar Pub.MarshalBinary: %v", err)
|
|
}
|
|
sigWire, err = sig.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("pulsar sig.MarshalBinary: %v", err)
|
|
}
|
|
return sigWire, gkWire
|
|
}
|
|
|
|
// TestDualPQ_AndMode_ParallelThresholdFinality is the POSITIVE end-to-end
|
|
// proof: a cert carrying BOTH a real Corona (Ring-LWE, multi-node t-of-n) leg
|
|
// AND a real Pulsar (FIPS-204 ML-DSA) leg, over the same message, with both
|
|
// group keys supplied, verifies.
|
|
func TestDualPQ_AndMode_ParallelThresholdFinality(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping dual-PQ Corona-DKG integration fixture under -short")
|
|
}
|
|
policy := dualPQPolicy()
|
|
store := &envStore{policy: policy}
|
|
cert, msg, vsetRoot := dualPQCertAndMessage(policy)
|
|
|
|
// Both legs sign the SAME envelope message, in parallel.
|
|
coronaSig, coronaGK, nodes := signCoronaLegMultiNode(t, dualPQThreshold, dualPQThreshold, dualPQN, msg)
|
|
pulsarSig, pulsarGK := signPulsarLegFIPS204(t, msg)
|
|
|
|
// Structural single-share check: exactly t separate per-node signers, each
|
|
// a distinct object (no shared aggregate-of-shares state).
|
|
if len(nodes) != dualPQThreshold {
|
|
t.Fatalf("expected %d per-node signers, got %d", dualPQThreshold, len(nodes))
|
|
}
|
|
seen := map[*coronaThreshold.Signer]bool{}
|
|
for _, nd := range nodes {
|
|
if nd.signer == nil || seen[nd.signer] {
|
|
t.Fatal("corona nodes must be distinct single-share signers")
|
|
}
|
|
seen[nd.signer] = true
|
|
}
|
|
|
|
// Compose the dual-PQ evidence via the production helper.
|
|
evidence, err := ComposeDualPQEvidence(pqParam, cert.SignerRoot, 150, pulsarSig, coronaSig)
|
|
if err != nil {
|
|
t.Fatalf("ComposeDualPQEvidence: %v", err)
|
|
}
|
|
cert.Evidence = evidence
|
|
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 9, classKeys: map[ClassicalScheme][]byte{},
|
|
coronaKey: coronaGK,
|
|
pulsarKey: pulsarGK,
|
|
}
|
|
|
|
// AND-mode verify: BOTH legs present, BOTH verify, both bound to the
|
|
// envelope. This is the dual-PQ finality predicate.
|
|
if err := VerifyConsensusCert(store, validators, cert); err != nil {
|
|
t.Fatalf("dual-PQ (Pulsar ‖ Corona) cert rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDualPQ_AndMode_SubQuorumCoronaFails proves the Corona leg is a real
|
|
// t-of-n threshold: a sub-quorum (t-1 signers) produces a signature that does
|
|
// not verify under the group key, so the dual-PQ cert is rejected even though
|
|
// the Pulsar leg is perfect.
|
|
func TestDualPQ_AndMode_SubQuorumCoronaFails(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping dual-PQ Corona-DKG integration fixture under -short")
|
|
}
|
|
policy := dualPQPolicy()
|
|
store := &envStore{policy: policy}
|
|
cert, msg, vsetRoot := dualPQCertAndMessage(policy)
|
|
|
|
// t-1 participants: a sub-quorum.
|
|
coronaSig, coronaGK, _ := signCoronaLegMultiNode(t, dualPQThreshold-1, dualPQThreshold, dualPQN, msg)
|
|
pulsarSig, pulsarGK := signPulsarLegFIPS204(t, msg)
|
|
|
|
evidence, err := ComposeDualPQEvidence(pqParam, cert.SignerRoot, 150, pulsarSig, coronaSig)
|
|
if err != nil {
|
|
t.Fatalf("ComposeDualPQEvidence: %v", err)
|
|
}
|
|
cert.Evidence = evidence
|
|
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 9, classKeys: map[ClassicalScheme][]byte{},
|
|
coronaKey: coronaGK, pulsarKey: pulsarGK,
|
|
}
|
|
|
|
// Sub-quorum Corona signature must fail the live Corona verify ⇒ cert
|
|
// rejected at the threshold-sig clause.
|
|
if err := VerifyConsensusCert(store, validators, cert); !errors.Is(err, ErrThresholdSigInvalid) {
|
|
t.Fatalf("sub-quorum Corona leg: err = %v, want ErrThresholdSigInvalid", err)
|
|
}
|
|
}
|
|
|
|
// TestDualPQ_AndMode_MissingPulsarLegFails proves AND-mode: a cert with a
|
|
// perfect Corona leg but NO Pulsar leg is rejected — the policy requires both.
|
|
func TestDualPQ_AndMode_MissingPulsarLegFails(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping dual-PQ Corona-DKG integration fixture under -short")
|
|
}
|
|
policy := dualPQPolicy()
|
|
store := &envStore{policy: policy}
|
|
cert, msg, vsetRoot := dualPQCertAndMessage(policy)
|
|
|
|
coronaSig, coronaGK, _ := signCoronaLegMultiNode(t, dualPQThreshold, dualPQThreshold, dualPQN, msg)
|
|
_, pulsarGK := signPulsarLegFIPS204(t, msg)
|
|
|
|
// Corona leg ONLY — the Pulsar required leg is absent.
|
|
cert.Evidence = []LegEvidence{{
|
|
Leg: LegSpec{Kind: LegCoronaLattice, ParamSetID: pqParam},
|
|
Mode: EvidenceThresholdSig,
|
|
Payload: encodeThresholdSigPayload(&ThresholdSigPayload{
|
|
Signature: EncodeCoronaSig(coronaSig),
|
|
Accountability: &ThresholdAccountability{SignerRoot: cert.SignerRoot, AggregateWeight: 150},
|
|
}),
|
|
}}
|
|
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 9, classKeys: map[ClassicalScheme][]byte{},
|
|
coronaKey: coronaGK, pulsarKey: pulsarGK,
|
|
}
|
|
|
|
if err := VerifyConsensusCert(store, validators, cert); err == nil {
|
|
t.Fatal("AND-mode violated: cert with a missing required Pulsar leg was accepted")
|
|
}
|
|
}
|
|
|
|
// TestDualPQ_AndMode_CorruptPulsarLegFails proves the Pulsar leg verify is
|
|
// live: a one-byte flip of the FIPS-204 signature is rejected even though the
|
|
// Corona leg is a valid t-of-n threshold signature.
|
|
func TestDualPQ_AndMode_CorruptPulsarLegFails(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping dual-PQ Corona-DKG integration fixture under -short")
|
|
}
|
|
policy := dualPQPolicy()
|
|
store := &envStore{policy: policy}
|
|
cert, msg, vsetRoot := dualPQCertAndMessage(policy)
|
|
|
|
coronaSig, coronaGK, _ := signCoronaLegMultiNode(t, dualPQThreshold, dualPQThreshold, dualPQN, msg)
|
|
pulsarSig, pulsarGK := signPulsarLegFIPS204(t, msg)
|
|
|
|
// Flip one byte of the Pulsar signature.
|
|
badPulsar := append([]byte(nil), pulsarSig...)
|
|
badPulsar[len(badPulsar)/2] ^= 0xFF
|
|
|
|
evidence, err := ComposeDualPQEvidence(pqParam, cert.SignerRoot, 150, badPulsar, coronaSig)
|
|
if err != nil {
|
|
t.Fatalf("ComposeDualPQEvidence: %v", err)
|
|
}
|
|
cert.Evidence = evidence
|
|
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 9, classKeys: map[ClassicalScheme][]byte{},
|
|
coronaKey: coronaGK, pulsarKey: pulsarGK,
|
|
}
|
|
|
|
if err := VerifyConsensusCert(store, validators, cert); !errors.Is(err, ErrThresholdSigInvalid) {
|
|
t.Fatalf("corrupt Pulsar leg: err = %v, want ErrThresholdSigInvalid", err)
|
|
}
|
|
}
|
|
|
|
// TestDualPQ_AndMode_CorruptCoronaLegFails proves the Corona leg verify is
|
|
// live: a one-byte flip of the Ring-LWE threshold signature is rejected even
|
|
// though the Pulsar leg is a valid FIPS-204 signature.
|
|
func TestDualPQ_AndMode_CorruptCoronaLegFails(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping dual-PQ Corona-DKG integration fixture under -short")
|
|
}
|
|
policy := dualPQPolicy()
|
|
store := &envStore{policy: policy}
|
|
cert, msg, vsetRoot := dualPQCertAndMessage(policy)
|
|
|
|
coronaSig, coronaGK, _ := signCoronaLegMultiNode(t, dualPQThreshold, dualPQThreshold, dualPQN, msg)
|
|
pulsarSig, pulsarGK := signPulsarLegFIPS204(t, msg)
|
|
|
|
coronaBytes := EncodeCoronaSig(coronaSig)
|
|
coronaBytes[len(coronaBytes)/2] ^= 0xFF // flip one byte
|
|
|
|
acct := &ThresholdAccountability{SignerRoot: cert.SignerRoot, AggregateWeight: 150}
|
|
cert.Evidence = []LegEvidence{
|
|
{
|
|
Leg: LegSpec{Kind: LegPulsarMLDSA, ParamSetID: pqParam},
|
|
Mode: EvidenceThresholdSig,
|
|
Payload: encodeThresholdSigPayload(&ThresholdSigPayload{Signature: pulsarSig, Accountability: acct}),
|
|
},
|
|
{
|
|
Leg: LegSpec{Kind: LegCoronaLattice, ParamSetID: pqParam},
|
|
Mode: EvidenceThresholdSig,
|
|
Payload: encodeThresholdSigPayload(&ThresholdSigPayload{Signature: coronaBytes, Accountability: acct}),
|
|
},
|
|
}
|
|
|
|
validators := &envValidators{
|
|
root: vsetRoot, epoch: 9, classKeys: map[ClassicalScheme][]byte{},
|
|
coronaKey: coronaGK, pulsarKey: pulsarGK,
|
|
}
|
|
|
|
if err := VerifyConsensusCert(store, validators, cert); !errors.Is(err, ErrThresholdSigInvalid) {
|
|
t.Fatalf("corrupt Corona leg: err = %v, want ErrThresholdSigInvalid", err)
|
|
}
|
|
}
|
|
|
|
// TestDualPQ_ComposeRejectsMissingLeg pins the composer's AND-mode contract: it
|
|
// refuses to build a dual-PQ evidence set with a missing leg, so a half cert is
|
|
// never even constructed.
|
|
func TestDualPQ_ComposeRejectsMissingLeg(t *testing.T) {
|
|
var sr [32]byte
|
|
if _, err := ComposeDualPQEvidence(pqParam, sr, 100, nil, nil); !errors.Is(err, ErrDualPQMissingPulsar) {
|
|
t.Fatalf("missing Pulsar: err = %v, want ErrDualPQMissingPulsar", err)
|
|
}
|
|
if _, err := ComposeDualPQEvidence(pqParam, sr, 100, []byte{1, 2, 3}, nil); !errors.Is(err, ErrDualPQMissingCorona) {
|
|
t.Fatalf("missing Corona: err = %v, want ErrDualPQMissingCorona", err)
|
|
}
|
|
}
|