test(parity): apples-to-apples Corona vs Pulsar bench harness

protocols/parity/parity_test.go is the canonical comparison harness for
the two threshold-signature schemes:

- Corona — Ring-LWE threshold sig (MAC-N×N-pairwise Corona-class).
- Pulsar — Shamir seed-reveal aggregator producing a FIPS 204 ML-DSA
  signature.

Both schemes are exercised through their threshold/protocols/<x> alias
surfaces. KAT tests use deterministic readers wherever possible; the
randomized=true path (FIPS 204) uses real RNG and is bench-only.

Run:
  go test -v -bench . -benchmem -run XXX ./protocols/parity/...
This commit is contained in:
Hanzo AI
2026-06-04 17:07:18 -07:00
parent b4d36e1829
commit 6c5c1e7ca6
+544
View File
@@ -0,0 +1,544 @@
// 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