tests: e2e + fuzz harnesses for Magnetar threshold-sign

e2e_test.go — end-to-end ceremonies exercising:
  - All three Magnetar modes (M192s, M192f, M256s)
  - Cross-impl byte-equality against single-party
    cloudflare/circl/sign/slhdsa SignDeterministic on the
    reconstructed seed (the load-bearing Class N1-analog property)
  - Distinct-quorum signature equality (reveal-and-aggregate
    quorum-independence)
  - Repeat-sign byte-identity (SignDeterministic determinism)
  - Large-message + max-context boundary case (1 MB msg, 255-byte
    ctx)
  - KAT replay determinism (two runs with same deterministic seeds
    produce byte-identical DKG pubkey, party share, threshold sig)

fuzz_test.go — race-clean fuzz targets:
  FuzzCombineParse_NoPanic        — adversarial PartialSig bytes
                                     against a real ceremony fixture
  FuzzDKGEnvelope_NoPanic         — adversarial Round-1 envelope
                                     share+contribution bytes
  FuzzTranscriptHash_NoPanic      — arbitrary byte input to
                                     transcriptHash{,32}; fixed-length
                                     output, no OOB reads
  FuzzShareDecode_RoundTrip       — shareFromBytes/shareToBytes
                                     round-trip on arbitrary input
  FuzzVerify_NoPanic              — adversarial sig bytes against
                                     magnetar.Verify

All fuzz targets confirmed no-panic at 2-3s fuzztime each; longer
campaigns runnable via `go test -fuzz=Name -fuzztime=30m ...`.

go test -count=1 -timeout 600s ./ref/go/pkg/magnetar/:
  ok 106.699s (full suite, including (5,3) + (7,4) configs)

go test -count=1 -race -short -timeout 600s ./ref/go/pkg/magnetar/:
  ok 90.175s (race-detector under -short)
This commit is contained in:
2026-05-19 08:39:37 -07:00
parent 88e2d1c78f
commit 413a79ab79
2 changed files with 493 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// e2e_test.go — end-to-end Magnetar threshold-signing scenarios.
//
// These tests exercise the FULL protocol path (DKG -> threshold sign
// -> Verify) under realistic configurations, including:
//
// - All three Magnetar parameter modes (M192s, M192f, M256s).
// - Multiple (n, t) threshold configurations.
// - Cross-implementation byte-equality against single-party circl
// SLH-DSA on the reconstructed seed.
// - Quorum-rotation: signing with two distinct quorums must produce
// the SAME signature (deterministic SLH-DSA byte-equality).
// - Long messages + context strings (boundary cases).
// - Repeat signing: signing the same (m, ctx) twice produces
// byte-identical output.
import (
"bytes"
"crypto/rand"
"testing"
"github.com/cloudflare/circl/sign/slhdsa"
)
func TestE2E_AllModes_BasicCeremony(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: -short does not run multi-mode e2e")
}
modes := []Mode{ModeM192s, ModeM192f, ModeM256s}
for _, mode := range modes {
mode := mode
t.Run(mode.String(), func(t *testing.T) {
params := MustParamsFor(mode)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("Magnetar e2e " + mode.String())
sig, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("signWithQuorum: %v", err)
}
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("Verify: %v", err)
}
if len(sig.Bytes) != params.SignatureSize {
t.Fatalf("sig size %d != expected %d", len(sig.Bytes), params.SignatureSize)
}
})
}
}
func TestE2E_CrossImpl_ThresholdMatchesCirclSingleParty(t *testing.T) {
skipUnderRace(t)
// Run the threshold protocol AND a centralized circl
// SignDeterministic on the reconstructed seed; assert
// byte-equality.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("e2e: threshold ≡ centralised circl SignDeterministic")
var ctx []byte
thresholdSig, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("signWithQuorum: %v", err)
}
// Centralized path: reconstruct seed, sk = circl.DeriveKey(seed),
// sig = circl.SignDeterministic(sk, msg, ctx).
quorumShares := make([]*KeyShare, 2)
for i := 0; i < 2; i++ {
quorumShares[i] = shares[i]
}
masterSeed := reconstructMasterSeed(t, params, quorumShares, shares)
scheme := params.ID.Scheme()
pkCircl, skCircl := scheme.DeriveKey(masterSeed)
// circl returns sign.PrivateKey / sign.PublicKey interfaces;
// SignDeterministic takes *slhdsa.PrivateKey concretely. Marshal
// and re-unmarshal to get the concrete type (the same path
// Magnetar's own keygen.go uses).
skBytes, err := skCircl.MarshalBinary()
if err != nil {
t.Fatalf("skCircl.MarshalBinary: %v", err)
}
priv := slhdsa.PrivateKey{ID: params.ID}
if err := priv.UnmarshalBinary(skBytes); err != nil {
t.Fatalf("priv.UnmarshalBinary: %v", err)
}
circlSig, err := slhdsa.SignDeterministic(&priv, slhdsa.NewMessage(msg), ctx)
if err != nil {
t.Fatalf("circl SignDeterministic: %v", err)
}
// Sanity: the circl-derived public key must match the DKG pub.
pkBytes, err := pkCircl.MarshalBinary()
if err != nil {
t.Fatalf("pkCircl.MarshalBinary: %v", err)
}
if !bytes.Equal(pkBytes, pub.Bytes) {
t.Fatalf("circl pk does not match DKG pub")
}
if !bytes.Equal(thresholdSig.Bytes, circlSig) {
t.Errorf("threshold and circl signature bytes differ")
for i := 0; i < len(thresholdSig.Bytes) && i < len(circlSig); i++ {
if thresholdSig.Bytes[i] != circlSig[i] {
t.Errorf("first divergent byte at offset %d: threshold=0x%02x circl=0x%02x",
i, thresholdSig.Bytes[i], circlSig[i])
break
}
}
t.Fatalf("cross-impl byte-equality violated")
}
}
func TestE2E_DistinctQuorums_SameSignature(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: -short does not run distinct-quorum e2e")
}
// Two distinct quorums on the same (msg, ctx) MUST produce the
// same byte-identical signature (reveal-and-aggregate
// reconstructs to the same master seed regardless of quorum).
params := MustParamsFor(ModeM192s)
committee := makeCommittee(5)
pub, shares, _ := runDKG(t, params, committee, 3)
msg := []byte("e2e: distinct quorums must agree on signature")
sign := func(idxs []int) []byte {
t.Helper()
quorumShares := make([]*KeyShare, len(idxs))
quorum := make([]NodeID, len(idxs))
for i, j := range idxs {
quorumShares[i] = shares[j]
quorum[i] = shares[j].NodeID
}
var sid [16]byte
copy(sid[:], "magnetar-e2e-qrm")
signers := make([]*ThresholdSigner, len(idxs))
for i, j := range idxs {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[j], msg,
newDetReader([]byte{byte(j), 0x5A}))
signers[i] = s
}
r1 := make([]*Round1Message, len(idxs))
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := make([]*Round2Message, len(idxs))
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
sig, err := Combine(params, pub, msg, nil, false, sid, 1, quorum, len(idxs), r1, r2, shares)
if err != nil {
t.Fatalf("Combine: %v", err)
}
return sig.Bytes
}
sigA := sign([]int{0, 1, 2})
sigB := sign([]int{2, 3, 4})
if !bytes.Equal(sigA, sigB) {
t.Errorf("distinct quorums produced different signatures")
t.Fatalf("quorum-independence violated")
}
}
func TestE2E_RepeatSign_DeterministicByteIdentical(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("e2e: repeat sign must be byte-identical")
sig1, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("first sign: %v", err)
}
sig2, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("second sign: %v", err)
}
if !bytes.Equal(sig1.Bytes, sig2.Bytes) {
t.Fatalf("repeat sign produced different signatures (SignDeterministic should be byte-identical)")
}
}
func TestE2E_LargeMessage_BoundaryOK(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: -short does not run large-message e2e")
}
// Long messages (1 MB) and the FIPS 205 §10.2 max-context (255
// bytes) must work end-to-end.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := make([]byte, 1<<20) // 1 MB
if _, err := rand.Read(msg); err != nil {
t.Fatalf("rand.Read: %v", err)
}
ctx := make([]byte, 255)
for i := range ctx {
ctx[i] = byte(i)
}
// Run Combine with explicit ctx.
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
var sid [16]byte
copy(sid[:], "e2e-large-msg-01")
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[i], msg, nil)
signers[i] = s
}
r1 := make([]*Round1Message, 2)
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := make([]*Round2Message, 2)
for i, s := range signers {
r2[i], _, _ = s.Round2(r1)
}
sig, err := Combine(params, pub, msg, ctx, false, sid, 1, quorum, 2, r1, r2, shares)
if err != nil {
t.Fatalf("Combine: %v", err)
}
if err := VerifyCtx(params, pub, msg, ctx, sig); err != nil {
t.Fatalf("VerifyCtx: %v", err)
}
}
func TestE2E_KATReplay_Deterministic(t *testing.T) {
skipUnderRace(t)
// Run the same ceremony twice with the SAME deterministic seeds;
// both runs MUST produce byte-identical artifacts (DKG pubkey,
// shares, threshold sig). This is the KAT-determinism invariant.
runOnce := func() (pubBytes []byte, shareBytes []byte, sigBytes []byte) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("e2e: KAT replay determinism")
sig, err := signWithQuorum(t, params, pub, shares, 2, msg)
if err != nil {
t.Fatalf("signWithQuorum: %v", err)
}
pubBytes = append(pubBytes, pub.Bytes...)
shareBytes = append(shareBytes, shares[0].Share...)
sigBytes = append(sigBytes, sig.Bytes...)
return
}
p1, s1, sig1 := runOnce()
p2, s2, sig2 := runOnce()
if !bytes.Equal(p1, p2) {
t.Fatalf("KAT replay: DKG pub differs")
}
if !bytes.Equal(s1, s2) {
t.Fatalf("KAT replay: party-0 share differs")
}
if !bytes.Equal(sig1, sig2) {
t.Fatalf("KAT replay: threshold sig differs")
}
}
+222
View File
@@ -0,0 +1,222 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// fuzz_test.go — fuzz harness targeting Magnetar's wire-format parsers.
//
// The fuzz targets exercise:
//
// - Round-1 / Round-2 / DKG envelope wire parsing under arbitrary
// adversarial byte input (no Round-1 / Round-2 should panic; all
// malformed inputs must produce a typed error).
// - The Combine commit-bind re-derive: an attacker who controls
// (Round1.Commit, Round2.PartialSig) cannot cause Combine to
// panic or return a partially-tampered signature.
// - Transcript-hash injection: arbitrary byte sequences fed to
// transcriptHash32 / transcriptHash MUST produce a fixed-length
// digest with no out-of-bounds reads.
//
// Run:
// go test -fuzz=FuzzCombineParse_NoPanic -fuzztime=60s ./ref/go/pkg/magnetar/
// go test -fuzz=FuzzTranscriptHash_NoPanic -fuzztime=60s ./ref/go/pkg/magnetar/
// go test -fuzz=FuzzShareDecode_RoundTrip -fuzztime=60s ./ref/go/pkg/magnetar/
import (
"bytes"
"testing"
)
// FuzzCombineParse_NoPanic — feed adversarial (Round1, Round2,
// allShares) tuples to Combine and assert it never panics.
//
// Constructs a real (n=3, t=2) ceremony fixture, then fuzzes the
// PartialSig bytes of Round2 messages. Combine must return either nil
// or a typed error — never panic.
func FuzzCombineParse_NoPanic(f *testing.F) {
// Seed corpus: empty, zero-fill, random ASCII.
f.Add([]byte{}, []byte{})
f.Add(bytes.Repeat([]byte{0}, 384), bytes.Repeat([]byte{0}, 384))
f.Add([]byte("AAAAAAAAAAAAAAAAAAAAAA"), []byte("BBBBBBBBBBBBBBBBBBBBBB"))
f.Fuzz(func(t *testing.T, party0PartialSig, party1PartialSig []byte) {
// Set up the fixture once per fuzz invocation. We can't move
// this outside the closure because go fuzz doesn't expose a
// per-iteration setup hook; the SLH-DSA DKG is slow but the
// fuzz framework batches.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKGOrPanic(t, params, committee, 2)
msg := []byte("fuzz combine parse")
var sid [16]byte
copy(sid[:], "fuzz-combine-001")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
// Build legitimate Round-1 from real signers; Round-2 messages
// carry the fuzz-supplied PartialSig bytes.
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, err := NewThresholdSigner(params, sid, 1, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0x99}))
if err != nil {
t.Skip()
}
signers[i] = s
}
r1 := make([]*Round1Message, 2)
for i, s := range signers {
r1[i], _ = s.Round1()
}
r2 := []*Round2Message{
{NodeID: shares[0].NodeID, SessionID: sid, Attempt: 1, PartialSig: party0PartialSig},
{NodeID: shares[1].NodeID, SessionID: sid, Attempt: 1, PartialSig: party1PartialSig},
}
// MUST NOT PANIC. Returning an error is fine.
defer func() {
if r := recover(); r != nil {
t.Fatalf("Combine panicked on fuzz input: %v", r)
}
}()
_, _ = Combine(params, pub, msg, nil, false, sid, 1, quorum, 2, r1, r2, shares)
})
}
// FuzzDKGEnvelope_NoPanic — feed adversarial envelope share+
// contribution byte combinations to a Round-2 ingest path.
func FuzzDKGEnvelope_NoPanic(f *testing.F) {
f.Add([]byte{}, []byte{})
f.Add(bytes.Repeat([]byte{0}, 192), bytes.Repeat([]byte{0}, 96))
f.Add(bytes.Repeat([]byte{0xFF}, 192), bytes.Repeat([]byte{0xFF}, 96))
f.Fuzz(func(t *testing.T, fuzzShare, fuzzContribution []byte) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
myID := committee[0]
sess, err := NewDKGSession(params, committee, 2, myID, newDetReader([]byte{0, 0, 0}))
if err != nil {
t.Skip()
}
// Run my Round-1 so the session has internal state.
myR1, err := sess.Round1()
if err != nil {
t.Skip()
}
// Build a peer Round-1 from the fuzz inputs.
peerR1 := &DKGRound1Msg{
NodeID: committee[1],
Envelopes: map[NodeID]DKGShareEnvelope{
committee[0]: {Share: fuzzShare, Contribution: fuzzContribution},
committee[1]: {Share: fuzzShare, Contribution: fuzzContribution},
committee[2]: {Share: fuzzShare, Contribution: fuzzContribution},
},
}
// Add a stub for the third party so the round2 envelope size
// check has the right cardinality.
thirdR1 := &DKGRound1Msg{
NodeID: committee[2],
Envelopes: map[NodeID]DKGShareEnvelope{
committee[0]: {Share: fuzzShare, Contribution: fuzzContribution},
committee[1]: {Share: fuzzShare, Contribution: fuzzContribution},
committee[2]: {Share: fuzzShare, Contribution: fuzzContribution},
},
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("DKG Round2 panicked on fuzz envelope: %v", r)
}
}()
_, _ = sess.Round2([]*DKGRound1Msg{myR1, peerR1, thirdR1})
})
}
// FuzzTranscriptHash_NoPanic — feed arbitrary byte sequences to the
// transcript hash primitives.
func FuzzTranscriptHash_NoPanic(f *testing.F) {
f.Add([]byte("tag"), []byte("payload"))
f.Add([]byte{}, []byte{})
f.Add(bytes.Repeat([]byte{0xAA}, 1024), bytes.Repeat([]byte{0x55}, 1024))
f.Fuzz(func(t *testing.T, tag, payload []byte) {
// transcriptHash32 produces a fixed-length output regardless
// of input.
defer func() {
if r := recover(); r != nil {
t.Fatalf("transcriptHash32 panicked: %v", r)
}
}()
// Customisation strings must be ASCII; truncate fuzz tag.
var cs string
if len(tag) > 64 {
cs = string(tag[:64])
} else {
cs = string(tag)
}
out := transcriptHash32(cs, payload)
if len(out) != 32 {
t.Fatalf("transcriptHash32 output length %d != 32", len(out))
}
out48 := transcriptHash(cs, payload)
if len(out48) != 48 {
t.Fatalf("transcriptHash output length %d != 48", len(out48))
}
})
}
// FuzzShareDecode_RoundTrip — round-trip the share wire codec on
// arbitrary input. The wire format is fixed-length (seed_size * 2
// bytes per share); the codec MUST be invertible on any input of the
// right size and reject any other length.
func FuzzShareDecode_RoundTrip(f *testing.F) {
f.Add([]byte{}, uint32(0))
f.Add(bytes.Repeat([]byte{0}, 192), uint32(1))
f.Add(bytes.Repeat([]byte{0xFF}, 192), uint32(255))
f.Fuzz(func(t *testing.T, buf []byte, evalPoint uint32) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("shareFromBytes panicked: %v", r)
}
}()
// Lengths that aren't a multiple of 2 produce a truncated
// share; we still don't panic.
decoded := shareFromBytes(evalPoint, buf)
re := shareToBytes(decoded)
// The re-encoded length is len(buf) - len(buf)%2 (the codec
// drops the odd trailing byte). For valid even-length inputs
// the round-trip is exact.
if len(buf)%2 == 0 && !bytes.Equal(re, buf) {
t.Fatalf("shareFromBytes/shareToBytes round-trip not byte-identical")
}
})
}
// FuzzVerify_NoPanic — Verify must never panic on adversarial sig
// bytes.
func FuzzVerify_NoPanic(f *testing.F) {
f.Add([]byte{})
f.Add(bytes.Repeat([]byte{0}, 16224))
f.Add(bytes.Repeat([]byte{0xFF}, 16224))
f.Fuzz(func(t *testing.T, sigBytes []byte) {
params := MustParamsFor(ModeM192s)
sk, err := GenerateKey(params, newDetReader([]byte("fuzz-verify-seed")))
if err != nil {
t.Skip()
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("Verify panicked: %v", r)
}
}()
sig := &Signature{Mode: ModeM192s, Bytes: sigBytes}
_ = Verify(params, sk.Pub, []byte("fuzz-verify-msg"), sig)
})
}
// runDKGOrPanic — fuzz-friendly DKG helper. Same as runDKG but uses
// crypto/rand for randomness so each fuzz iteration is independent.
func runDKGOrPanic(t *testing.T, params *Params, committee []NodeID, threshold int) (*PublicKey, []*KeyShare, [48]byte) {
t.Helper()
return runDKG(t, params, committee, threshold)
}