Files
consensus/protocol/quasar/wave_signer.go
zeekay 24b7cd2154 consensus: route corona + pulsar through threshold/protocols alias
Replaces every direct import of luxfi/corona/* and luxfi/pulsar/ref/go
in consensus with the canonical luxfi/threshold/protocols/{corona,pulsar}
alias surface. Pure import swap + go.mod bump; no behaviour change.

Files touched (16):

  bench/pq_modes_bench_test.go
  engine/pq/crypto.go
  engine/pq/engine.go
  pkg/wire/policies.go
  protocol/quasar/cert_size_compare_test.go
  protocol/quasar/corona_gob.go
  protocol/quasar/dual_threshold_test.go
  protocol/quasar/epoch.go
  protocol/quasar/epoch_test.go
  protocol/quasar/gpu_accel.go
  protocol/quasar/grouped_threshold.go
  protocol/quasar/quasar.go
  protocol/quasar/types.go
  protocol/quasar/wave_signer.go
  protocol/quasar/wave_signer_test.go
  protocol/quasar/witness.go
  protocol/quasar/witness_pulsar_test.go

Import path swaps:

  luxfi/corona/threshold        -> luxfi/threshold/protocols/corona
  luxfi/corona/keyera           -> luxfi/threshold/protocols/corona/keyera
  luxfi/corona/primitives       -> luxfi/threshold/protocols/corona/primitives
  luxfi/corona/reshare          -> luxfi/threshold/protocols/corona/reshare
  luxfi/corona/sign             -> luxfi/threshold/protocols/corona/sign
  luxfi/corona/gpu              -> luxfi/threshold/protocols/corona/gpu
  luxfi/pulsar/ref/go/pkg/pulsar -> luxfi/threshold/protocols/pulsar

The aliased package name "coronaThreshold" used by quasar / engine/pq /
pkg/wire identifiers is preserved at the call sites (it now refers to
the threshold/protocols/corona alias package). engine/pq/crypto.go is
the only file where the package was bare-imported as `threshold`; that
identifier was renamed to `corona` to match the new alias path.

go.mod:

  - luxfi/threshold bumped to a branch pseudo-version that carries the
    new alias subpackages; will pin to a stable tag once threshold's
    companion commit lands and a release is cut.
  - luxfi/corona and luxfi/pulsar drop to indirect (consensus now
    reaches them only via threshold/protocols).
  - luxfi/math v1.4.0 -> v1.4.1 and luxfi/metric v1.5.0 -> v1.5.7 are
    transitive minimum-version bumps pulled by the new threshold
    requirement; they are no-op for consensus call sites.

Comment-only references to "github.com/luxfi/corona/..." in bls.go,
round_digest.go, reshare_epoch.go, comprehensive_test.go,
engine_test.go, quasar_bench_test.go and config/pq_mode.go are
documentation pointers to where the underlying implementation lives;
they are accurate either way and are left unchanged (out of refactor
scope).

Layering shape after:

  consensus -> threshold/protocols/{corona,pulsar}  (stable alias)
                 |
                 v
              luxfi/corona, luxfi/pulsar  (primitive implementations)

Closes the 22-file layering violation flagged by the 2026-05-31
integration audit. Tests pass: ./engine/pq/..., ./pkg/wire/...,
./protocol/quasar/... (all key cases — TestEpochManager*,
TestRoundSigner*, TestVerifyPQFinality*, TestBLSThreshold*,
TestDualSigningFlow, TestCoronaCertSize* — green; the pre-existing
TestGroupedEpochManager_ParallelKeygen perf-bound flake on slow
machines was reproduced on unmodified main, so it is not a
regression).
2026-05-31 12:39:47 -07:00

248 lines
8.6 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package quasar
// wave_signer.go -- thin adapter that drives a Pulsar threshold-sign
// ceremony from one Lux round (one Wave Tick over a Prism.Cut sample).
//
// Architecture (matches pulsar/spec/system-model.tex "Committee
// selection and PQ rollup"):
//
// 1. Prism.Cut.Sample(K) picks a fresh K-validator committee from
// the validator pool each Lux round.
// 2. The K sampled validators run Pulsar threshold (3, 2) (or any
// (T, K) with T <= K) on the round's item.
// 3. The output is a single FIPS 204 ML-DSA signature on the item.
// 4. Many such per-round signatures roll up via P3Q at the
// Z-Chain envelope path (consensus/protocol/zchain) to produce
// the final cert.
//
// This file is the minimum viable surface. The full Wave + Focus
// driver wiring (β-confidence amplification, multi-round
// accumulation) belongs at the Wave consumer layer.
import (
"context"
"crypto/rand"
"errors"
"io"
"github.com/luxfi/consensus/protocol/prism"
"github.com/luxfi/ids"
"github.com/luxfi/threshold/protocols/pulsar"
"golang.org/x/crypto/sha3"
)
// witnessQuorumSessionKeys synthesizes the per-signer session-key
// maps that pulsar v1.0.7's NewThresholdSigner requires.
//
// Pulsar's protocol assumes each signer is a distinct node with a
// long-term identity key; pairs derive symmetric session keys via
// ML-KEM-768 + KMAC bound to (sessionID, transcript). The consensus
// witness pattern runs all T threshold signers inside one
// coordinator process (the RoundSigner): peer-to-peer envelope
// encryption is degenerate because every "peer" is the same process,
// but the v1.0.7 API still requires a non-nil session-key map.
//
// We synthesize fresh IdentityKeys per quorum member and derive the
// pairwise symmetric keys exactly as a distributed run would. The
// resulting keys are cryptographically valid; the witness pattern's
// security model is "single trusted coordinator holds all shares"
// regardless. Splitting the coordinator into K distinct nodes is the
// general-case deployment (one validator per share) and reuses the
// same key derivation path.
func witnessQuorumSessionKeys(quorum []pulsar.NodeID, sessionID [16]byte, transcript []byte, rng io.Reader) (map[pulsar.NodeID]map[pulsar.NodeID][32]byte, error) {
keys := make(map[pulsar.NodeID]*pulsar.IdentityKey, len(quorum))
for _, id := range quorum {
ik, err := pulsar.GenerateIdentity(rng)
if err != nil {
return nil, err
}
keys[id] = ik
}
out := make(map[pulsar.NodeID]map[pulsar.NodeID][32]byte, len(quorum))
for _, id := range quorum {
out[id] = make(map[pulsar.NodeID][32]byte, len(quorum)-1)
}
for i := 0; i < len(quorum); i++ {
for j := i + 1; j < len(quorum); j++ {
a, b := quorum[i], quorum[j]
key, err := pulsar.SymmetricSession(a, keys[a], b, keys[b], sessionID, transcript)
if err != nil {
return nil, err
}
out[a][b] = key
out[b][a] = key
}
}
return out, nil
}
// RoundResult is the output of one Lux round of Pulsar threshold
// signing: a FIPS 204 ML-DSA signature on the round item under the
// committee's shared group public key.
type RoundResult struct {
// Item is the item that was signed (the value passed to the Wave
// round).
Item []byte
// GroupPubkey is the Pulsar group public key (FIPS 204 ML-DSA
// public key format) under which the signature verifies.
GroupPubkey *pulsar.PublicKey
// Signature is the FIPS 204 ML-DSA signature bytes -- byte-equal
// to what a single-party FIPS 204 signer would emit on the same
// (group pubkey, item) pair.
Signature *pulsar.Signature
// Committee is the K-validator sample that produced the
// signature, in canonical NodeID order.
Committee []pulsar.NodeID
}
// RoundSigner drives one Lux round of Pulsar (T, K) threshold
// signing using a Prism.Cut to pick the K-validator committee.
type RoundSigner struct {
Params *pulsar.Params
Cut prism.Cut[ids.NodeID] // K-sampler over the validator pool
K int // Sample size
Threshold int // T -- minimum honest signers to combine
// Shares maps each potential validator NodeID to its Pulsar
// KeyShare. The K-sample pulls from this map; missing entries
// cause that validator to be skipped (it cannot contribute).
Shares map[ids.NodeID]*pulsar.KeyShare
// PrecompileCtx is the FIPS 204 context string the resulting
// signature is bound to. Pass `[]byte("lux-evm-precompile-pulsar-v1")`
// for on-chain Pulsar precompile verification; pass a different
// chain-specific tag for in-protocol verification. Empty / nil
// produces a context-free signature.
PrecompileCtx []byte
}
// RunRound performs one Lux round of Pulsar threshold signing.
// Returns the FIPS 204 ML-DSA signature on item under the group
// public key. The signature verifies through unmodified FIPS 204
// ML-DSA.Verify(group_pk, item, signer.PrecompileCtx, sig).
func (s *RoundSigner) RunRound(ctx context.Context, item []byte) (*RoundResult, error) {
if s.Cut == nil {
return nil, errors.New("RoundSigner: Cut is nil")
}
if s.K < s.Threshold || s.Threshold < 1 {
return nil, errors.New("RoundSigner: invalid (T, K)")
}
// Sample K validators for this Lux round.
sampled := s.Cut.Sample(s.K)
if len(sampled) < s.Threshold {
return nil, errors.New("RoundSigner: sample short of threshold")
}
// Translate to Pulsar NodeIDs and collect shares.
committee := make([]pulsar.NodeID, 0, len(sampled))
allShares := make([]*pulsar.KeyShare, 0, len(sampled))
for _, id := range sampled {
var pid pulsar.NodeID
copy(pid[:], id[:])
share, ok := s.Shares[id]
if !ok {
continue
}
committee = append(committee, pid)
allShares = append(allShares, share)
if len(committee) == s.K {
break
}
}
if len(committee) < s.Threshold {
return nil, errors.New("RoundSigner: too few shares to meet threshold")
}
// Take the first T as the signing quorum (deterministic; the
// Wave consumer can re-sample if a member is unavailable).
quorum := committee[:s.Threshold]
groupPK := allShares[0].Pub
// Per-round session id binds the Pulsar PRNG to this Lux round.
// The Wave consumer typically derives this from
// (epoch, round-counter, item-hash, committee-root); here we
// take the leading 16 bytes of SHA3-256 over the item + first
// committee NodeID to keep the signature deterministic across
// replays of the same Lux round.
var sessionID [16]byte
h := sha3.NewLegacyKeccak256()
h.Write(item)
h.Write(committee[0][:])
copy(sessionID[:], h.Sum(nil))
// Reverse map: pulsar.NodeID -> ids.NodeID via byte copy, used
// to look up the share-owner's KeyShare. Both types alias to a
// 32-byte array but Go's type system requires an explicit copy.
idsByPulsarID := make(map[pulsar.NodeID]ids.NodeID, len(committee))
for id := range s.Shares {
var pid pulsar.NodeID
copy(pid[:], id[:])
idsByPulsarID[pid] = id
}
// Derive the per-signer session-key maps (v1.0.7 identity stage).
// Transcript binding is (item || groupPK || committee-leader) so
// session keys differ across rounds even with the same sessionID.
transcript := make([]byte, 0, len(item)+len(groupPK.Bytes)+32)
transcript = append(transcript, item...)
transcript = append(transcript, groupPK.Bytes...)
transcript = append(transcript, committee[0][:]...)
witnessSK, err := witnessQuorumSessionKeys(quorum, sessionID, transcript, rand.Reader)
if err != nil {
return nil, err
}
// Spin up a ThresholdSigner per quorum member.
signers := make([]*pulsar.ThresholdSigner, s.Threshold)
for i := 0; i < s.Threshold; i++ {
ts, err := pulsar.NewThresholdSigner(
s.Params, sessionID, 0, quorum,
s.Shares[idsByPulsarID[quorum[i]]],
witnessSK[quorum[i]],
item, rand.Reader,
)
if err != nil {
return nil, err
}
signers[i] = ts
}
// Round 1 -- commits + MACs.
r1 := make([]*pulsar.Round1Message, s.Threshold)
for i, ts := range signers {
m, err := ts.Round1(item)
if err != nil {
return nil, err
}
r1[i] = m
}
// Round 2 -- reveals.
r2 := make([]*pulsar.Round2Message, s.Threshold)
for i, ts := range signers {
m, _, err := ts.Round2(r1)
if err != nil {
return nil, err
}
r2[i] = m
}
// Combine -- single FIPS 204 ML-DSA signature output. Pass the
// caller's precompile / verifier context so the signature is
// bound to its intended verifier.
sig, err := pulsar.Combine(
s.Params, groupPK, item, s.PrecompileCtx, false,
sessionID, 0, quorum, s.Threshold, r1, r2, allShares,
)
if err != nil {
return nil, err
}
return &RoundResult{
Item: item,
GroupPubkey: groupPK,
Signature: sig,
Committee: committee,
}, nil
}