Files
threshold/protocols/parity/parity_test.go
zeekay 44ea481306 threshold: kill the ringtail wire-compat alias
Per CTO directive — no wire compat, no alias, no shim. The R-LWE
threshold signature scheme is named Corona across all live Lux
code. Pre-2026-06 callers using "ringtail" must migrate to "corona"
on the procedure-routing surface.

- Drop schemeAliases map + canonicalScheme() from types.go.
- Delete TestCoronaRingtailWireAlias from thresholdd_test.go.
- protocols/parity test comment sweep.

Forward-only; no migration window, no fallback path.
2026-06-06 13:56:29 -07:00

545 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package parity holds the apples-to-apples bench harness comparing
// Corona (Ring-LWE threshold sig, MAC-N×N-pairwise Corona-class
// scheme) against Pulsar (Shamir-seed-reveal aggregator producing a
// FIPS 204 ML-DSA signature). Both schemes are exercised through their
// canonical threshold/protocols/<x> alias surfaces.
//
// Run with:
//
// go test -v -bench . -benchmem -run XXX ./protocols/parity/...
//
// or per-N:
//
// go test -v -bench BenchmarkCorona_N64 -benchtime=5x ./protocols/parity/
//
// The KAT cross-check (TestKAT_Corona_FixedSeed,
// TestKAT_Pulsar_FixedSeed) emits hashes that should be stable across
// runs on the same machine; cross-machine equality is NOT guaranteed
// because Pulsar's per-party RNG includes a `rand.Reader` mix in some
// paths (the test uses deterministic readers everywhere we can, but
// the FIPS 204 mldsaSign call uses real RNG for the randomized=true
// path — we therefore run randomized=false in the KAT for byte-equal
// output and randomized=true in the bench for realistic timing).
package parity
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"runtime"
"sync"
"testing"
"time"
corona "github.com/luxfi/threshold/protocols/corona"
pulsar "github.com/luxfi/threshold/protocols/pulsar"
// Upstream pulsar package is also reachable directly; we use it
// only when the alias surface omits a specific helper (e.g.
// EstablishSession is the alias for SymmetricSession + KEM under
// the hood; for bench we use the lighter SymmetricSession path).
pulsarkernel "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
)
// -------------------------------------------------------------------
// Deterministic-reader helper. Used so KAT and bench are reproducible
// across runs on a given machine.
// -------------------------------------------------------------------
type detReader struct {
state [32]byte
pos int
buf [32]byte
}
func newDetReader(seed []byte) *detReader {
r := &detReader{}
h := sha256.Sum256(seed)
r.state = h
r.buf = h
return r
}
func (r *detReader) Read(p []byte) (int, error) {
n := 0
for n < len(p) {
if r.pos >= len(r.buf) {
r.state = sha256.Sum256(r.state[:])
r.buf = r.state
r.pos = 0
}
k := copy(p[n:], r.buf[r.pos:])
r.pos += k
n += k
}
return len(p), nil
}
// -------------------------------------------------------------------
// Corona harness.
//
// Corona is the Ring-LWE threshold scheme from luxfi/corona. Each party
// holds an MLWE share + per-pair PRF seeds + per-pair MAC keys; Round 1
// broadcasts the D matrix + pairwise MACs; Round 2 broadcasts the z
// vector; Finalize aggregates z + emits Delta.
//
// In Corona the share population is "all parties signed"; the t-of-n
// is handled by Lagrange-coefficient weighting (party.Lambda). The
// kernel uses sign.K = n and sign.Threshold = t globals — we
// instantiate one (params, shares, groupKey) per benchmark size and
// reuse across iterations.
// -------------------------------------------------------------------
type coronaSetup struct {
groupKey *corona.GroupKey
shares []*corona.KeyShare
signers []*corona.Signer
n int
t int
prfKey []byte
message string
sessID int
signerIx []int // signer indices in T (0..t-1 for simplicity)
}
func makeCoronaSetup(b testing.TB, n, t int, seed []byte) *coronaSetup {
rng := newDetReader(append(append([]byte{}, seed...), 0xC0))
shares, gk, err := corona.GenerateKeys(t, n, rng)
if err != nil {
b.Fatalf("corona.GenerateKeys(t=%d,n=%d): %v", t, n, err)
}
signers := make([]*corona.Signer, n)
for i := 0; i < n; i++ {
signers[i] = corona.NewSigner(shares[i])
}
signerIx := make([]int, t)
for i := 0; i < t; i++ {
signerIx[i] = i
}
prfKey := make([]byte, 32)
if _, err := io.ReadFull(rng, prfKey); err != nil {
b.Fatalf("read prf key: %v", err)
}
return &coronaSetup{
groupKey: gk,
shares: shares,
signers: signers,
n: n,
t: t,
prfKey: prfKey,
message: "parity-bench-corona-vs-pulsar",
sessID: 42,
signerIx: signerIx,
}
}
// coronaSignParallel runs Round1 in parallel for each signer in T,
// then Round2 in parallel, then Finalize on signer 0, then Verify.
// Returns elapsed per phase and the final signature for KAT hashing.
func coronaSignParallel(b testing.TB, s *coronaSetup) (sig *corona.Signature, perPhase [4]time.Duration) {
t := s.t
// Round 1 — parallel.
round1 := make([]*corona.Round1Data, t)
r1Start := time.Now()
var wg sync.WaitGroup
wg.Add(t)
for i := 0; i < t; i++ {
go func(i int) {
defer wg.Done()
round1[i] = s.signers[s.signerIx[i]].Round1(s.sessID, s.prfKey, s.signerIx)
}(i)
}
wg.Wait()
perPhase[0] = time.Since(r1Start)
// Index by party ID for Round2.
r1Map := make(map[int]*corona.Round1Data, t)
for _, m := range round1 {
r1Map[m.PartyID] = m
}
// Round 2 — parallel.
round2 := make([]*corona.Round2Data, t)
r2Start := time.Now()
wg.Add(t)
for i := 0; i < t; i++ {
go func(i int) {
defer wg.Done()
d, err := s.signers[s.signerIx[i]].Round2(s.sessID, s.message, s.prfKey, s.signerIx, r1Map)
if err != nil {
b.Errorf("corona round2 party %d: %v", i, err)
return
}
round2[i] = d
}(i)
}
wg.Wait()
perPhase[1] = time.Since(r2Start)
r2Map := make(map[int]*corona.Round2Data, t)
for _, m := range round2 {
if m != nil {
r2Map[m.PartyID] = m
}
}
// Finalize on the first signer in T.
finStart := time.Now()
sig, err := s.signers[s.signerIx[0]].Finalize(r2Map)
perPhase[2] = time.Since(finStart)
if err != nil {
b.Fatalf("corona finalize: %v", err)
}
// Verify.
verStart := time.Now()
ok := corona.Verify(s.groupKey, s.message, sig)
perPhase[3] = time.Since(verStart)
if !ok {
b.Fatalf("corona verify failed")
}
return sig, perPhase
}
// -------------------------------------------------------------------
// Pulsar harness.
//
// Pulsar: a Shamir-shared 32-byte master seed. Each party holds a
// 64-byte share. The signing protocol XOR-masks the share in Round 1,
// reveals (mask, masked) in Round 2, the aggregator Shamir-
// reconstructs the master seed and runs FIPS 204 ML-DSA Sign once.
//
// To stage realistic Pulsar key shares we run the actual upstream
// Pulsar DKG once (n parties × 3 rounds). DKG cost is excluded from
// the per-round bench timer. We use ModeP65 to match Lux production.
// -------------------------------------------------------------------
type pulsarSetup struct {
params *pulsar.Params
committee []pulsar.NodeID
idKeys map[pulsar.NodeID]*pulsar.IdentityKey
idDirectory pulsar.IdentityDirectory
pub *pulsar.PublicKey
shares []*pulsar.KeyShare
t int
n int
message []byte
quorum []pulsar.NodeID
sessKeys map[pulsar.NodeID]map[pulsar.NodeID][32]byte
}
func makePulsarSetup(b testing.TB, n, t int, seed []byte) *pulsarSetup {
params := pulsar.MustParamsFor(pulsar.ModeP65)
committee := make([]pulsar.NodeID, n)
for i := range committee {
// Deterministic NodeIDs: tag with seed + index.
h := sha256.Sum256(append(append([]byte{}, seed...), byte(0x4E), byte(i), byte(i>>8)))
copy(committee[i][:], h[:])
}
idKeys := make(map[pulsar.NodeID]*pulsar.IdentityKey, n)
pubs := make(map[pulsar.NodeID]*pulsar.IdentityPublicKey, n)
for i, id := range committee {
rng := newDetReader(append(append([]byte{}, seed...), 0x4B, byte(i)))
k, err := pulsar.GenerateIdentity(rng)
if err != nil {
b.Fatalf("pulsar GenerateIdentity %d: %v", i, err)
}
idKeys[id] = k
pubs[id] = k.PublicKey()
}
directory, err := pulsar.NewIdentityDirectory(pubs)
if err != nil {
b.Fatalf("pulsar NewIdentityDirectory: %v", err)
}
// Run DKG using the upstream kernel directly — this is the
// canonical DKG entry. We stage it once per (n,t) outside the
// bench timer.
sessions := make([]*pulsarkernel.DKGSession, n)
for i := range sessions {
rng := newDetReader(append(append([]byte{}, seed...), 0x44, byte(i)))
s, err := pulsar.NewDKGSession(params, committee, t, committee[i], idKeys[committee[i]], directory, rng)
if err != nil {
b.Fatalf("pulsar NewDKGSession %d: %v", i, err)
}
sessions[i] = s
}
r1 := make([]*pulsar.DKGRound1Msg, n)
for i, s := range sessions {
m, err := s.Round1()
if err != nil {
b.Fatalf("pulsar dkg.Round1 %d: %v", i, err)
}
r1[i] = m
}
r2 := make([]*pulsar.DKGRound2Msg, n)
for i, s := range sessions {
m, err := s.Round2(r1)
if err != nil {
b.Fatalf("pulsar dkg.Round2 %d: %v", i, err)
}
r2[i] = m
}
outputs := make([]*pulsar.DKGOutput, n)
for i, s := range sessions {
o, err := s.Round3(r1, r2)
if err != nil {
b.Fatalf("pulsar dkg.Round3 %d: %v", i, err)
}
outputs[i] = o
}
pub := outputs[0].GroupPubkey
shares := make([]*pulsar.KeyShare, n)
for i := range outputs {
shares[i] = outputs[i].SecretShare
}
quorum := make([]pulsar.NodeID, t)
for i := 0; i < t; i++ {
quorum[i] = shares[i].NodeID
}
// Session-key pre-exchange for the quorum.
var sid [16]byte
copy(sid[:], "parity-bench-pulsr")
message := []byte("parity-bench-corona-vs-pulsar")
sessKeys := make(map[pulsar.NodeID]map[pulsar.NodeID][32]byte, t)
for _, id := range quorum {
sessKeys[id] = make(map[pulsar.NodeID][32]byte, t-1)
}
for i := 0; i < t; i++ {
for j := i + 1; j < t; j++ {
a, c := quorum[i], quorum[j]
key, err := pulsar.SymmetricSession(a, idKeys[a], c, idKeys[c], sid, message)
if err != nil {
b.Fatalf("pulsar SymmetricSession: %v", err)
}
sessKeys[a][c] = key
sessKeys[c][a] = key
}
}
return &pulsarSetup{
params: params,
committee: committee,
idKeys: idKeys,
idDirectory: directory,
pub: pub,
shares: shares,
t: t,
n: n,
message: message,
quorum: quorum,
sessKeys: sessKeys,
}
}
func pulsarSignParallel(b testing.TB, s *pulsarSetup, iter uint32) (sig *pulsar.Signature, perPhase [4]time.Duration) {
t := s.t
var sid [16]byte
binary.BigEndian.PutUint32(sid[:4], iter)
copy(sid[4:], "parity-bench-px") // 12 bytes
// Per-party signer construction (also part of Round 1 setup cost).
signers := make([]*pulsar.ThresholdSigner, t)
for i := 0; i < t; i++ {
ks := s.shares[i].NodeID
rng := newDetReader([]byte{byte(iter), byte(iter >> 8), byte(i)})
var err error
signers[i], err = pulsar.NewThresholdSigner(s.params, sid, 1, s.quorum, s.shares[i], s.sessKeys[ks], s.message, rng)
if err != nil {
b.Fatalf("pulsar NewThresholdSigner: %v", err)
}
}
// Round 1 — parallel.
sr1 := make([]*pulsar.Round1Message, t)
r1Start := time.Now()
var wg sync.WaitGroup
wg.Add(t)
for i := 0; i < t; i++ {
go func(i int) {
defer wg.Done()
m, err := signers[i].Round1(s.message)
if err != nil {
b.Errorf("pulsar Round1 %d: %v", i, err)
return
}
sr1[i] = m
}(i)
}
wg.Wait()
perPhase[0] = time.Since(r1Start)
// Round 2 — parallel.
sr2 := make([]*pulsar.Round2Message, t)
r2Start := time.Now()
wg.Add(t)
for i := 0; i < t; i++ {
go func(i int) {
defer wg.Done()
m, _, err := signers[i].Round2(sr1)
if err != nil {
b.Errorf("pulsar Round2 %d: %v", i, err)
return
}
sr2[i] = m
}(i)
}
wg.Wait()
perPhase[1] = time.Since(r2Start)
// Combine.
combStart := time.Now()
sig, err := pulsar.Combine(s.params, s.pub, s.message, nil, false, sid, 1, s.quorum, t, sr1, sr2, s.shares)
perPhase[2] = time.Since(combStart)
if err != nil {
b.Fatalf("pulsar Combine: %v", err)
}
// Verify.
verStart := time.Now()
verr := pulsar.VerifyCtx(s.params, s.pub, s.message, nil, sig)
perPhase[3] = time.Since(verStart)
if verr != nil {
b.Fatalf("pulsar Verify: %v", verr)
}
return sig, perPhase
}
// -------------------------------------------------------------------
// KAT cross-checks. We fix a seed and assert each scheme produces a
// verifiable signature.
// -------------------------------------------------------------------
func TestKAT_Corona_FixedSeed(t *testing.T) {
n, th := 8, 5
s := makeCoronaSetup(t, n, th, []byte("kat-corona-2026-06-03"))
sig, perPhase := coronaSignParallel(t, s)
t.Logf("Corona N=%d t=%d R1=%v R2=%v Fin=%v Ver=%v",
n, th, perPhase[0], perPhase[1], perPhase[2], perPhase[3])
// Just hash the C polynomial coefficient bytes as a fingerprint.
hash := sha256.New()
hash.Write([]byte(fmt.Sprintf("%v", sig.C)))
t.Logf("Corona sig fingerprint: %s", hex.EncodeToString(hash.Sum(nil))[:32])
if !corona.Verify(s.groupKey, s.message, sig) {
t.Fatalf("KAT: corona verify failed")
}
}
func TestKAT_Pulsar_FixedSeed(t *testing.T) {
n, th := 8, 5
s := makePulsarSetup(t, n, th, []byte("kat-pulsar-2026-06-03"))
sig, perPhase := pulsarSignParallel(t, s, 1)
t.Logf("Pulsar N=%d t=%d R1=%v R2=%v Comb=%v Ver=%v",
n, th, perPhase[0], perPhase[1], perPhase[2], perPhase[3])
t.Logf("Pulsar sig %d bytes, hash=%s", len(sig.Bytes), hex.EncodeToString(sha256.New().Sum(sig.Bytes))[:32])
// Stronger: pull byte-equal Verify under cloudflare/circl mldsa65.
if err := pulsar.VerifyCtx(s.params, s.pub, s.message, nil, sig); err != nil {
t.Fatalf("KAT: pulsar verify failed: %v", err)
}
}
// TestKAT_BothSchemesVerifyTheirOwnSignatures is the canonical
// correctness gate. It fails fast if either upstream changes its
// signature format or breaks verification.
func TestKAT_BothSchemesVerifyTheirOwnSignatures(t *testing.T) {
const (
smallN = 4
smallT = 3
)
cs := makeCoronaSetup(t, smallN, smallT, []byte("kat-both-corona"))
csig, _ := coronaSignParallel(t, cs)
if !corona.Verify(cs.groupKey, cs.message, csig) {
t.Fatalf("corona self-verify failed")
}
// Pulsar: smallest t the FIPS 204 reconstruction tolerates.
ps := makePulsarSetup(t, smallN, smallT, []byte("kat-both-pulsar"))
psig, _ := pulsarSignParallel(t, ps, 1)
if err := pulsar.VerifyCtx(ps.params, ps.pub, ps.message, nil, psig); err != nil {
t.Fatalf("pulsar self-verify failed: %v", err)
}
// Also: assert that the Pulsar signature verifies under
// unmodified FIPS 204 — the Class N1 interchangeability claim.
if err := pulsarkernel.Verify(ps.params, ps.pub, ps.message, psig); err != nil {
t.Fatalf("pulsar threshold sig does NOT verify under FIPS 204: %v", err)
}
t.Logf("Both schemes self-verify; Pulsar additionally verifies under unmodified FIPS 204.")
}
// -------------------------------------------------------------------
// Per-N benchmarks. Both schemes share the same N/t inputs.
// t = ceil(2N/3).
// -------------------------------------------------------------------
func bench2Of3(n int) int { return (2*n + 2) / 3 }
func benchCoronaN(b *testing.B, n int) {
t := bench2Of3(n)
if t >= n {
t = n - 1
}
s := makeCoronaSetup(b, n, t, []byte(fmt.Sprintf("bench-corona-N%d", n)))
b.ReportAllocs()
b.ResetTimer()
var phases [4]time.Duration
for i := 0; i < b.N; i++ {
_, p := coronaSignParallel(b, s)
for k := range phases {
phases[k] += p[k]
}
}
if b.N > 0 {
b.ReportMetric(float64(phases[0].Nanoseconds())/float64(b.N)/1e6, "ms/R1")
b.ReportMetric(float64(phases[1].Nanoseconds())/float64(b.N)/1e6, "ms/R2")
b.ReportMetric(float64(phases[2].Nanoseconds())/float64(b.N)/1e6, "ms/Final")
b.ReportMetric(float64(phases[3].Nanoseconds())/float64(b.N)/1e6, "ms/Verify")
}
}
func benchPulsarN(b *testing.B, n int) {
t := bench2Of3(n)
if t >= n {
t = n - 1
}
s := makePulsarSetup(b, n, t, []byte(fmt.Sprintf("bench-pulsar-N%d", n)))
b.ReportAllocs()
b.ResetTimer()
var phases [4]time.Duration
for i := 0; i < b.N; i++ {
_, p := pulsarSignParallel(b, s, uint32(i+1))
for k := range phases {
phases[k] += p[k]
}
}
if b.N > 0 {
b.ReportMetric(float64(phases[0].Nanoseconds())/float64(b.N)/1e6, "ms/R1")
b.ReportMetric(float64(phases[1].Nanoseconds())/float64(b.N)/1e6, "ms/R2")
b.ReportMetric(float64(phases[2].Nanoseconds())/float64(b.N)/1e6, "ms/Comb")
b.ReportMetric(float64(phases[3].Nanoseconds())/float64(b.N)/1e6, "ms/Verify")
}
}
func BenchmarkCorona_N16(b *testing.B) { benchCoronaN(b, 16) }
func BenchmarkCorona_N32(b *testing.B) { benchCoronaN(b, 32) }
func BenchmarkCorona_N64(b *testing.B) { benchCoronaN(b, 64) }
func BenchmarkCorona_N128(b *testing.B) { benchCoronaN(b, 128) }
func BenchmarkPulsar_N16(b *testing.B) { benchPulsarN(b, 16) }
func BenchmarkPulsar_N32(b *testing.B) { benchPulsarN(b, 32) }
func BenchmarkPulsar_N64(b *testing.B) { benchPulsarN(b, 64) }
func BenchmarkPulsar_N128(b *testing.B) { benchPulsarN(b, 128) }
// TestBenchEnvironment prints the machine and Go env so the report
// can pin numbers to a configuration.
func TestBenchEnvironment(t *testing.T) {
t.Logf("Go: %s GOOS=%s GOARCH=%s NumCPU=%d GOMAXPROCS=%d",
runtime.Version(), runtime.GOOS, runtime.GOARCH,
runtime.NumCPU(), runtime.GOMAXPROCS(0))
}
// keep linker happy if bytes is unused on a future edit.
var _ = bytes.Equal