feat: v0.1 reveal-and-aggregate threshold-SLH-DSA implementation

ref/go/pkg/magnetar/: pure-Go threshold FIPS 205 SLH-DSA over
byte-wise Shamir VSS of the SLH-DSA scheme seed. Mirrors Pulsar's
v0.1 reveal-and-aggregate pattern, ported to SLH-DSA's
scheme-seed-as-secret model.

Construction:
- DKG (dkg.go): Shamir+sum over GF(257), per-recipient envelopes
  carry both the recipient's share AND the dealer's full
  contribution so every party can compute the joint master public
  key locally. Round-2 digest binds the ordered envelope set for
  identifiable abort (ComplaintEquivocation).
- Threshold-sign (threshold.go, combine.go): two-round commit-and-
  reveal of (mask, masked_share). Aggregator XORs to recover
  shares, Lagrange-interpolates the byte-sum, mixes with
  committee_root via cSHAKE256, and calls
  slhdsa.SignDeterministic on the reconstructed seed. Output
  signature is byte-identical to single-party FIPS 205.
- Three parameter sets: SHAKE-192s (recommended), SHAKE-192f,
  SHAKE-256s.

Backend: github.com/cloudflare/circl v1.6.3 (sign/slhdsa). Pure Go,
no CGo. circl.Scheme().DeriveKey(seed) is the byte-deterministic
seed -> keypair path that Magnetar Shamir-shares over.

Headline test (n1_byte_equality_test.go):
- TestN1_ByteEquality_ThresholdMatchesCentralized validates the
  Class-N1-analog claim: threshold-produced signatures are
  byte-identical to single-party SignDeterministic on the
  reconstructed master seed across (3,2), (5,3), (7,4) configs.
- TestN1_ByteEquality_DifferentQuorumsSameSignature validates
  quorum-independence: distinct quorums yield identical bytes.

Test discipline:
- Non-race full suite: 34 top-level tests, all PASS in ~56s.
- Race-detector run: SLH-DSA hash-tree is 5-10x slower under race
  on commodity hardware; SLH-DSA-heavy tests self-skip via
  raceEnabled build-tag constant (race_on_test.go / race_off_test.go)
  so race tests focus on the cheap concurrency-relevant primitives
  (shamir, transcript, types) — these pass in ~1.4s under
  -race -timeout 240s.

Honest v0.1 trust caveat: aggregator process is TCB for the brief
window the master seed is reconstructed in memory. Same caveat as
Pulsar v0.1 reveal-and-aggregate. Documented in SPEC.md and
DEPLOYMENT-RUNBOOK.md (separate commits). All secret-bearing
buffers are explicitly zeroized at every return path in combine.go.
This commit is contained in:
Hanzo AI
2026-05-19 01:24:51 -07:00
parent 4e8d1657ed
commit 7f91286ee3
25 changed files with 3662 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Build / test scratch directories
vectors-first/
vectors-second/
vectors-tmp/
# Editor / OS
.DS_Store
*.swp
*~
+10
View File
@@ -0,0 +1,10 @@
module github.com/luxfi/magnetar
go 1.26.3
require (
github.com/cloudflare/circl v1.6.3
golang.org/x/crypto v0.32.0
)
require golang.org/x/sys v0.29.0 // indirect
+6
View File
@@ -0,0 +1,6 @@
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+206
View File
@@ -0,0 +1,206 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// combine.go — aggregator-side reconstruction and single-party
// FIPS 205 Sign on the reconstructed master seed.
//
// This file IS the v0.1 reveal-and-aggregate trust caveat: the
// master seed lives in this process's memory for the duration of
// one Combine call. Every reconstructed-seed code path explicitly
// zeroizes the seed before return. No defer: the call-tree is
// short, and explicit zeroization at each return site keeps the
// secret lifetime locally legible.
//
// Class-N1-analog: this function produces a signature byte-
// identical to single-party FIPS 205 SignDeterministic on the
// reconstructed master seed. Any FIPS 205 verifier accepts the
// output with no code change.
import (
"crypto/rand"
)
// Combine produces a FIPS 205 SLH-DSA signature from a quorum of
// Round-2 reveals.
//
// Parameters:
// - params, groupPubkey, message, ctx: match what was passed to
// NewThresholdSigner.
// - randomized: when false, uses SLH-DSA deterministic signing
// (Class-N1-analog reproducible output). When true, uses
// randomized signing with the supplied rng.
// - sessionID, attempt, quorum: match the signing session.
// - threshold: minimum number of Round-2 reveals required.
// - round1, round2: messages from the signing committee.
// - allShares: directory of KeyShares (used to map NodeID →
// EvalPoint for Lagrange interpolation).
//
// Combine is a pure function — it does not require ThresholdSigner
// state. Any honest party in the quorum (or an external
// aggregator) can call Combine after Round 2 completes.
//
// The returned Signature, when passed to Verify(params,
// groupPubkey, message, sig), returns nil (i.e. the signature is
// FIPS 205 valid).
func Combine(
params *Params,
groupPubkey *PublicKey,
message []byte,
ctx []byte,
randomized bool,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
threshold int,
round1 []*Round1Message,
round2 []*Round2Message,
allShares []*KeyShare,
) (*Signature, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if groupPubkey == nil {
return nil, ErrNilPublicKey
}
if len(round1) < threshold || len(round2) < threshold {
return nil, ErrInsufficientQuor
}
maskLen := params.SeedSize * 2
// Index Round-1 messages by sender.
r1ByID := make(map[NodeID]*Round1Message, len(round1))
for _, m := range round1 {
if m.SessionID != sessionID || m.Attempt != attempt {
return nil, ErrSessionMismatch
}
r1ByID[m.NodeID] = m
}
// For each Round-2 reveal: re-derive D_i and compare against
// the matching Round-1 commit.
revealedShares := make(map[NodeID][]byte, threshold)
for _, r2 := range round2 {
r1, ok := r1ByID[r2.NodeID]
if !ok {
continue
}
if r2.SessionID != sessionID || r2.Attempt != attempt {
return nil, ErrSessionMismatch
}
if len(r2.PartialSig) != 2*maskLen {
return nil, ErrRound2CommitBad
}
mask := r2.PartialSig[:maskLen]
masked := r2.PartialSig[maskLen:]
// Re-derive D_i = cSHAKE256(mask || masked || tau_1).
tau := transcriptTau1Bytes(sessionID, attempt, quorum, r2.NodeID, groupPubkey, message)
commitInput := make([]byte, 0, len(mask)+len(masked)+len(tau))
commitInput = append(commitInput, mask...)
commitInput = append(commitInput, masked...)
commitInput = append(commitInput, tau...)
recomputed := transcriptHash32(tagSignR1, commitInput)
if !ctEqual32(recomputed, r1.Commit) {
return nil, ErrRound2CommitBad
}
// Recover share = masked XOR mask.
share := make([]byte, maskLen)
for i := 0; i < maskLen; i++ {
share[i] = masked[i] ^ mask[i]
}
revealedShares[r2.NodeID] = share
}
if len(revealedShares) < threshold {
return nil, ErrInsufficientQuor
}
// Pair each revealed share with its KeyShare entry to recover
// the EvalPoint. The aggregator gets allShares as a directory
// so it can map NodeID → EvalPoint.
keyShareByID := make(map[NodeID]*KeyShare, len(allShares))
for _, ks := range allShares {
keyShareByID[ks.NodeID] = ks
}
shares := make([]shamirShare, 0, threshold)
for id, sBytes := range revealedShares {
ks, ok := keyShareByID[id]
if !ok {
return nil, ErrNotInQuorum
}
if len(sBytes) != params.SeedSize*2 {
return nil, ErrShareWireSize
}
shares = append(shares, shareFromBytes(ks.EvalPoint, sBytes))
if len(shares) == threshold {
break
}
}
// Lagrange-reconstruct the GF(257) byte-sum. The committee
// root is canonical given allShares.
byteSum, err := shamirReconstructGF(shares, params.SeedSize)
if err != nil {
return nil, err
}
committeeRoot := committeeRootFromShares(allShares)
byteSumBytes := make([]byte, params.SeedSize*2)
for b := 0; b < params.SeedSize; b++ {
byteSumBytes[2*b] = byte(byteSum[b] >> 8)
byteSumBytes[2*b+1] = byte(byteSum[b])
}
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
masterSeed := cshake256(mixInput, params.SeedSize, tagSeedShare)
// Derive the centralized FIPS 205 SK from the master seed.
// Every error path AND the success path below MUST explicitly
// zeroize the reconstructed secret material before return.
sk, err := KeyFromSeed(params, masterSeed)
if err != nil {
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(byteSum)
return nil, err
}
// Sanity: the reconstructed pubkey MUST match groupPubkey,
// else the aggregator's share set is wrong / tampered.
if !sk.Pub.Equal(groupPubkey) {
zeroizePrivateKey(sk)
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(byteSum)
return nil, ErrPubkeyMismatch
}
// FIPS 205 sign with the reconstructed seed-derived key.
// randomized=false → SignDeterministic (Class-N1-analog
// reproducible output). randomized=true uses the caller's rng.
signRng := rand.Reader
sigBytes, err := slhSign(params.ID, sk.Bytes, message, ctx, randomized, signRng)
if err != nil {
zeroizePrivateKey(sk)
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(byteSum)
return nil, err
}
// Success path: signature is OK; wipe every secret-bearing
// buffer before returning.
zeroizePrivateKey(sk)
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(byteSum)
return &Signature{Mode: params.Mode, Bytes: sigBytes}, nil
}
+456
View File
@@ -0,0 +1,456 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// dkg.go — Threshold DKG via byte-wise Shamir VSS over the SLH-DSA
// scheme seed.
//
// Protocol shape (v0.1 reveal-and-aggregate, Shamir+sum):
//
// Round 1 (dealer P_i):
// Sample a random contribution c_i ∈ {0,1}^{seed_size}.
// Shamir-share c_i across the committee at threshold t.
// For each recipient P_j, broadcast Envelope_{i,j} =
// (Share_{i,j}, c_i)
// where Share_{i,j} is P_j's per-byte Shamir share of c_i and
// c_i is the full contribution (duplicated into every envelope
// so each party can sum contributions at Round 2).
//
// Round 2 (party P_i):
// Verify all received envelopes are well-formed. Compute the
// digest D_i = cSHAKE256(ordered envelope set || tau_dkg)
// where tau_dkg = (mode, committee, threshold). Broadcast
// (NodeID_i, D_i).
//
// Round 3 (party P_i):
// Verify all received digests match. Sum the per-byte Shamir
// shares: my_share = Σ_j Share_{j,i} (mod 257). Sum the
// contributions: master_byteSum = Σ_j c_j (per byte, mod 257).
// Mix master_byteSum with the committee root via cSHAKE256 to
// get the master seed; derive the group public key via
// KeyFromSeed. The (NodeID, EvalPoint, my_share, GroupPubkey)
// tuple becomes the party's KeyShare.
//
// The v0.1 trust caveat: the aggregator process at sign time
// reconstructs the master seed in memory. See SPEC.md "Trust
// model" and DEPLOYMENT-RUNBOOK.md.
//
// Round-1 envelopes are PLAINTEXT in v0.1 (committee members trust
// each other already since each will see the reconstructed seed
// at sign time). v0.2 wraps envelopes under ML-KEM-768 to close
// the network-observer channel while keeping the same trust model
// vis-a-vis committee members.
import (
"crypto/rand"
"errors"
"io"
)
// Errors returned by DKG.
var (
ErrDKGUnexpectedRound = errors.New("magnetar: DKG message from unexpected round")
ErrDKGCommitteeMismatch = errors.New("magnetar: DKG message committee mismatch")
ErrDKGMissingEnvelope = errors.New("magnetar: DKG missing envelope for this party")
ErrDKGShareSize = errors.New("magnetar: DKG envelope share has wrong size")
ErrDKGContribSize = errors.New("magnetar: DKG envelope contribution has wrong size")
ErrDKGDigestMismatch = errors.New("magnetar: DKG Round-2 digest mismatch — equivocation suspected")
ErrDKGShareTooShort = errors.New("magnetar: DKG share count below threshold")
)
// DKGSession holds one party's state for a Magnetar DKG ceremony.
type DKGSession struct {
Params *Params
Committee []NodeID // canonical-ordered committee, indexed 1..n
Threshold int
MyID NodeID
rng io.Reader
// myIndex is myID's position in committee (0-indexed).
myIndex int
// myContribution is THIS party's c_i. Held across rounds.
myContribution []byte
// myShares[j] is P_j's Shamir share of myContribution, for
// j ∈ 0..n-1.
myShares []shamirShare
// myEvalPoint is the party's Shamir x-coordinate (1-indexed).
myEvalPoint uint32
// receivedR1 caches Round-1 messages by sender for use in Round 2/3.
receivedR1 map[NodeID]*DKGRound1Msg
// receivedR2 caches Round-2 digests by sender.
receivedR2 map[NodeID]*DKGRound2Msg
}
// NewDKGSession constructs a fresh DKG session for committee C
// with threshold t. myID must be a member of C. rng may be nil
// (crypto/rand is used).
//
// Committee ordering: caller passes the canonical sorted committee.
// EvalPoints are derived as (committee_index + 1), matching the
// Pulsar pattern.
func NewDKGSession(params *Params, committee []NodeID, threshold int, myID NodeID, rng io.Reader) (*DKGSession, error) {
if err := params.Validate(); err != nil {
return nil, err
}
n := len(committee)
if threshold < 1 || n < threshold {
return nil, ErrInvalidThreshold
}
if n > MaxCommittee257 {
return nil, ErrCommitteeTooLarge
}
myIndex := -1
for i, id := range committee {
if id == myID {
myIndex = i
break
}
}
if myIndex < 0 {
return nil, ErrNotInQuorum
}
if rng == nil {
rng = rand.Reader
}
return &DKGSession{
Params: params,
Committee: append([]NodeID{}, committee...),
Threshold: threshold,
MyID: myID,
rng: rng,
myIndex: myIndex,
myEvalPoint: uint32(myIndex + 1),
receivedR1: make(map[NodeID]*DKGRound1Msg, n),
receivedR2: make(map[NodeID]*DKGRound2Msg, n),
}, nil
}
// Round1 samples this party's contribution and emits the per-
// recipient envelope broadcast.
func (s *DKGSession) Round1() (*DKGRound1Msg, error) {
if s.myContribution != nil {
return nil, ErrDKGUnexpectedRound
}
n := len(s.Committee)
t := s.Threshold
// Sample the contribution.
s.myContribution = make([]byte, s.Params.SeedSize)
if _, err := io.ReadFull(s.rng, s.myContribution); err != nil {
return nil, ErrShortRand
}
// Sample the polynomial coefficient stream from rng — enough
// bytes to feed (t-1) × seed_size × 2 bytes of coefficient
// material. cshake256 will be used as a fallback inside shamirDealRandomGF
// when the stream is short.
needed := (t - 1) * s.Params.SeedSize * 2
if needed < 2 {
needed = 2
}
coeffStream := make([]byte, needed)
if _, err := io.ReadFull(s.rng, coeffStream); err != nil {
return nil, ErrShortRand
}
shares, err := shamirDealRandom(s.myContribution, n, t, coeffStream)
if err != nil {
return nil, err
}
s.myShares = shares
// Build per-recipient envelopes.
envs := make(map[NodeID]DKGShareEnvelope, n)
for j := 0; j < n; j++ {
envs[s.Committee[j]] = DKGShareEnvelope{
Share: shareToBytes(shares[j]),
Contribution: append([]byte{}, s.myContribution...),
}
}
return &DKGRound1Msg{
NodeID: s.MyID,
Envelopes: envs,
}, nil
}
// Round2 ingests every party's Round-1 envelopes, validates them,
// and emits this party's transcript-digest broadcast.
func (s *DKGSession) Round2(round1 []*DKGRound1Msg) (*DKGRound2Msg, error) {
if s.myContribution == nil {
return nil, ErrDKGUnexpectedRound
}
n := len(s.Committee)
expectShareLen := s.Params.SeedSize * 2
expectContribLen := s.Params.SeedSize
committeeSet := make(map[NodeID]struct{}, n)
for _, id := range s.Committee {
committeeSet[id] = struct{}{}
}
for _, m := range round1 {
if _, ok := committeeSet[m.NodeID]; !ok {
return nil, ErrDKGCommitteeMismatch
}
// Must contain an envelope for this party.
env, ok := m.Envelopes[s.MyID]
if !ok {
return nil, ErrDKGMissingEnvelope
}
if len(env.Share) != expectShareLen {
return nil, ErrDKGShareSize
}
if len(env.Contribution) != expectContribLen {
return nil, ErrDKGContribSize
}
// Must contain an envelope for every committee member.
for _, peer := range s.Committee {
if _, has := m.Envelopes[peer]; !has {
return nil, ErrDKGMissingEnvelope
}
}
s.receivedR1[m.NodeID] = m
}
if len(s.receivedR1) != n {
return nil, ErrDKGCommitteeMismatch
}
// Compute the transcript digest. The digest binds:
// - tau_dkg = (mode, committee, threshold, domain-sep)
// - the ordered envelope-set: for each dealer in committee
// order, for each recipient in committee order, the
// envelope bytes.
//
// Both this party and every honest peer compute the same
// digest from the same envelope set — equivocation by a
// dealer who broadcasts distinct envelopes to distinct
// recipients is detected at Round 3 as a digest mismatch.
digest := s.computeR2Digest()
return &DKGRound2Msg{
NodeID: s.MyID,
Digest: digest,
}, nil
}
// computeR2Digest computes the transcript digest used in Round 2.
// Pure function of (params, committee, threshold, receivedR1).
func (s *DKGSession) computeR2Digest() [32]byte {
parts := [][]byte{
[]byte(tagDomainSep),
{byte(s.Params.Mode)},
intLE(uint32(s.Threshold)),
intLE(uint32(len(s.Committee))),
}
for _, id := range s.Committee {
parts = append(parts, id[:])
}
// For each dealer (committee order), for each recipient
// (committee order), the envelope's share+contribution.
for _, dealerID := range s.Committee {
m := s.receivedR1[dealerID]
if m == nil {
parts = append(parts, []byte("MISSING"))
continue
}
for _, recipientID := range s.Committee {
env := m.Envelopes[recipientID]
parts = append(parts, env.Share)
parts = append(parts, env.Contribution)
}
}
return transcriptHash32(tagDKGTranscript, parts...)
}
// Round3 ingests Round-1 envelopes (cached in Round 2) and Round-2
// digests. Produces the party's KeyShare and the joint group
// public key.
//
// Returns DKGOutput with AbortEvidence != nil on detected
// misbehavior (digest mismatch). The caller is expected to
// broadcast the AbortEvidence for slashing.
func (s *DKGSession) Round3(round2 []*DKGRound2Msg) (*DKGOutput, error) {
if len(s.receivedR1) == 0 {
return nil, ErrDKGUnexpectedRound
}
n := len(s.Committee)
if len(round2) < n {
return nil, ErrDKGShareTooShort
}
// Index round2 by sender; verify all match my digest.
myDigest := s.computeR2Digest()
committeeSet := make(map[NodeID]struct{}, n)
for _, id := range s.Committee {
committeeSet[id] = struct{}{}
}
for _, m := range round2 {
if _, ok := committeeSet[m.NodeID]; !ok {
return nil, ErrDKGCommitteeMismatch
}
s.receivedR2[m.NodeID] = m
}
for _, id := range s.Committee {
m, ok := s.receivedR2[id]
if !ok {
return nil, ErrDKGCommitteeMismatch
}
if !ctEqual32(m.Digest, myDigest) {
// Identifiable abort: digest mismatch indicates either
// the accused dealer equivocated or this party's view
// of the envelope set differs. Build a complaint with
// the conflicting digest pair as evidence.
return &DKGOutput{
AbortEvidence: &AbortEvidence{
Kind: ComplaintEquivocation,
Accuser: s.MyID,
Accused: m.NodeID,
Evidence: append(append([]byte{}, myDigest[:]...), m.Digest[:]...),
},
}, nil
}
}
// Sum the per-byte shares received from each dealer to get
// THIS party's share of the joint master byte-sum.
seedSize := s.Params.SeedSize
mySumShare := make([]uint16, seedSize)
for _, dealerID := range s.Committee {
env := s.receivedR1[dealerID].Envelopes[s.MyID]
// Deserialize the envelope's share for this party.
share := shareFromBytes(s.myEvalPoint, env.Share)
for b := 0; b < seedSize; b++ {
mySumShare[b] = uint16((uint32(mySumShare[b]) + uint32(share.Y[b])) % shamirPrime)
}
}
// Sum the contributions to get the master byte-sum (this is
// the same value mySumShare would Lagrange-interpolate to at
// x=0 when combined with a threshold of other parties).
masterByteSum := make([]uint16, seedSize)
for _, dealerID := range s.Committee {
env := s.receivedR1[dealerID].Envelopes[s.MyID]
for b := 0; b < seedSize; b++ {
masterByteSum[b] = uint16((uint32(masterByteSum[b]) + uint32(env.Contribution[b])) % shamirPrime)
}
}
// Mix the byteSum with the committee root to get the master
// seed. This step domain-separates the seed from the raw
// Shamir reconstruction and gives constant-length output
// regardless of how many parties contributed.
committeeRoot := committeeRootFromIDs(s.Committee)
byteSumBytes := make([]byte, seedSize*2)
for b := 0; b < seedSize; b++ {
byteSumBytes[2*b] = byte(masterByteSum[b] >> 8)
byteSumBytes[2*b+1] = byte(masterByteSum[b])
}
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
masterSeed := cshake256(mixInput, seedSize, tagSeedShare)
// Derive the group key.
sk, err := KeyFromSeed(s.Params, masterSeed)
if err != nil {
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(masterByteSum)
return nil, err
}
groupPub := sk.Pub
// We DON'T retain sk — only the public part is published.
// Wipe sk before continuing.
zeroizePrivateKey(sk)
// Compute the transcript hash for chain pinning. 48-byte
// digest binding (committee_root, group_pubkey, threshold).
transcript := transcriptHash(tagDKGTranscript,
[]byte(tagDomainSep),
committeeRoot[:],
groupPub.Bytes,
intLE(uint32(s.Threshold)),
)
// Construct THIS party's KeyShare. The share field carries
// the per-byte Shamir share of the master byte-sum. The
// aggregator at Combine reconstructs the byte-sum from t
// such shares via Lagrange interpolation, then mixes with
// committee_root via cSHAKE256 — bit-for-bit matching the
// DKG mix above.
shareBytes := make([]byte, seedSize*2)
for b := 0; b < seedSize; b++ {
shareBytes[2*b] = byte(mySumShare[b] >> 8)
shareBytes[2*b+1] = byte(mySumShare[b])
}
out := &DKGOutput{
GroupPubkey: groupPub,
SecretShare: &KeyShare{
NodeID: s.MyID,
EvalPoint: s.myEvalPoint,
Share: shareBytes,
Pub: groupPub,
Mode: s.Params.Mode,
},
TranscriptHash: transcript,
}
// Wipe intermediate buffers.
zeroizeBytes(masterSeed)
zeroizeBytes(mixInput)
zeroizeBytes(byteSumBytes)
zeroizeU16Slice(masterByteSum)
zeroizeU16Slice(mySumShare)
zeroizeBytes(s.myContribution)
for _, sh := range s.myShares {
zeroizeU16Slice(sh.Y)
}
s.myContribution = nil
s.myShares = nil
return out, nil
}
// committeeRootFromIDs computes the canonical 32-byte digest of
// the sorted committee. Used by DKG and Combine to bind the share
// reconstruction to the exact committee that the DKG installed.
func committeeRootFromIDs(ids []NodeID) [32]byte {
sorted := append([]NodeID{}, ids...)
for i := 1; i < len(sorted); i++ {
for j := i; j > 0 && nodeIDLess(sorted[j], sorted[j-1]); j-- {
sorted[j], sorted[j-1] = sorted[j-1], sorted[j]
}
}
parts := make([][]byte, 0, len(sorted)+1)
parts = append(parts, []byte("MAGNETAR-COMMITTEE-V1"))
for _, id := range sorted {
parts = append(parts, id[:])
}
return transcriptHash32(tagDKGCommit, parts...)
}
// committeeRootFromShares is the KeyShare-keyed variant used by
// Combine when the aggregator only has the directory of shares.
func committeeRootFromShares(shares []*KeyShare) [32]byte {
ids := make([]NodeID, 0, len(shares))
for _, s := range shares {
ids = append(ids, s.NodeID)
}
return committeeRootFromIDs(ids)
}
// intLE returns x as a 4-byte little-endian slice. Used in
// transcript binding.
func intLE(x uint32) []byte {
return []byte{byte(x), byte(x >> 8), byte(x >> 16), byte(x >> 24)}
}
// ErrNotInQuorum is reused from the threshold layer; declared
// here to avoid forward-declaration issues at first compilation.
var ErrNotInQuorum = errors.New("magnetar: party not in committee/quorum")
+212
View File
@@ -0,0 +1,212 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
import (
"testing"
)
func TestDKG_BasicAgreement(t *testing.T) {
skipUnderRace(t)
// (n=3, t=2): smallest non-trivial committee. Every party
// must agree on the joint group pubkey and transcript hash.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, transcript := runDKG(t, params, committee, 2)
if pub == nil {
t.Fatalf("nil group pubkey")
}
if len(pub.Bytes) != params.PublicKeySize {
t.Fatalf("group pubkey size: got %d want %d", len(pub.Bytes), params.PublicKeySize)
}
if len(shares) != 3 {
t.Fatalf("expected 3 shares, got %d", len(shares))
}
// Every share carries the right wire size.
for i, s := range shares {
if len(s.Share) != params.SeedSize*2 {
t.Fatalf("share[%d] wire size: got %d want %d", i, len(s.Share), params.SeedSize*2)
}
if !s.Pub.Equal(pub) {
t.Fatalf("share[%d].Pub differs from group pubkey", i)
}
if s.EvalPoint == 0 {
t.Fatalf("share[%d] has zero eval point", i)
}
}
// Transcript is non-zero.
allZero := true
for _, b := range transcript {
if b != 0 {
allZero = false
break
}
}
if allZero {
t.Fatalf("transcript is all zero")
}
}
func TestDKG_AllConfigurations(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: SLH-DSA DKG with n>=5 is slow under -short")
}
configs := []struct{ N, T int }{
{3, 2}, {5, 3}, {7, 4}, {10, 7},
}
for _, cfg := range configs {
cfg := cfg
t.Run(modeStringConfig(cfg.N, cfg.T), func(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(cfg.N)
pub, shares, _ := runDKG(t, params, committee, cfg.T)
if pub == nil {
t.Fatalf("nil group pubkey")
}
if len(shares) != cfg.N {
t.Fatalf("expected %d shares, got %d", cfg.N, len(shares))
}
})
}
}
func TestDKG_EquivocationDetected(t *testing.T) {
skipUnderRace(t)
// Tamper with one party's Round-2 digest and check that
// honest parties produce AbortEvidence at Round 3.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
n := 3
sessions := make([]*DKGSession, n)
for i := range sessions {
seed := []byte{byte(i), 0xDD, 0xCC}
s, _ := NewDKGSession(params, committee, 2, committee[i], newDetReader(seed))
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, n)
for i, s := range sessions {
r1[i], _ = s.Round1()
}
r2 := make([]*DKGRound2Msg, n)
for i, s := range sessions {
r2[i], _ = s.Round2(r1)
}
// Tamper with r2[1]'s digest.
r2[1].Digest[0] ^= 0x01
// Honest party 0 runs Round 3 — must detect the equivocation.
out, err := sessions[0].Round3(r2)
if err != nil {
t.Fatalf("Round3: %v", err)
}
if out.AbortEvidence == nil {
t.Fatalf("expected AbortEvidence on digest mismatch")
}
if out.AbortEvidence.Kind != ComplaintEquivocation {
t.Fatalf("expected ComplaintEquivocation, got %v", out.AbortEvidence.Kind)
}
}
func TestDKG_MissingEnvelopeRejected(t *testing.T) {
skipUnderRace(t)
// A dealer that omits the recipient's envelope is rejected
// at Round 2.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
sessions := make([]*DKGSession, 3)
for i := range sessions {
seed := []byte{byte(i), 0xEE}
s, _ := NewDKGSession(params, committee, 2, committee[i], newDetReader(seed))
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, 3)
for i, s := range sessions {
r1[i], _ = s.Round1()
}
// Strip party 0's envelope from dealer 1.
delete(r1[1].Envelopes, committee[0])
if _, err := sessions[0].Round2(r1); err == nil {
t.Fatalf("expected ErrDKGMissingEnvelope")
}
}
func TestDKG_NonMemberRejected(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
var stranger NodeID
stranger[0] = 0xFF
if _, err := NewDKGSession(params, committee, 2, stranger, nil); err == nil {
t.Fatalf("expected ErrNotInQuorum for non-member")
}
}
func TestDKG_InvalidThresholdRejected(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
if _, err := NewDKGSession(params, committee, 0, committee[0], nil); err == nil {
t.Fatalf("expected ErrInvalidThreshold (t=0)")
}
if _, err := NewDKGSession(params, committee, 4, committee[0], nil); err == nil {
t.Fatalf("expected ErrInvalidThreshold (t > n)")
}
}
func TestDKG_DeterministicAcrossRuns(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: two DKG runs with n=5 are slow under -short")
}
// Two runs of the DKG with the same deterministic seeds must
// produce byte-identical group pubkeys, transcripts, and
// shares.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(5)
pub1, sh1, tr1 := runDKG(t, params, committee, 3)
pub2, sh2, tr2 := runDKG(t, params, committee, 3)
if !pub1.Equal(pub2) {
t.Fatalf("group pubkey not deterministic")
}
if tr1 != tr2 {
t.Fatalf("transcript hash not deterministic")
}
for i := range sh1 {
if string(sh1[i].Share) != string(sh2[i].Share) {
t.Fatalf("share[%d] bytes not deterministic", i)
}
}
}
func modeStringConfig(n, t int) string {
return formatConfig(n, t)
}
func formatConfig(n, t int) string {
return spfNT(n, t)
}
func spfNT(n, t int) string {
return itoa(t) + "of" + itoa(n)
}
func itoa(v int) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var digits [12]byte
i := len(digits)
for v > 0 {
i--
digits[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
digits[i] = '-'
}
return string(digits[i:])
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package magnetar implements the Magnetar v0.1 threshold FIPS 205
// SLH-DSA primitive. It mirrors Pulsar's reveal-and-aggregate
// pattern but over SLH-DSA's scheme seed instead of ML-DSA's.
//
// Construction summary (v0.1):
//
// - Threshold DKG: each party contributes a random scheme-seed.
// Contributions are byte-wise Shamir-shared via GF(257) (n
// parties, t threshold) and broadcast in per-recipient
// envelopes. Each envelope ALSO carries the full
// contribution so every party can sum the contributions to
// compute the joint master public key locally. The party's
// KeyShare is the sum of received shares (one per dealer).
//
// - Threshold sign: each signer commits to a mask + masked-share
// in Round 1, reveals (mask, masked_share) in Round 2. The
// aggregator gathers t reveals, XORs to recover the underlying
// share, Lagrange-interpolates the byte-sum, applies the same
// cSHAKE256 mix as the DKG to recover the master seed, and
// calls single-party FIPS 205 SignDeterministic. The resulting
// signature is byte-identical to single-party SLH-DSA on the
// same (master_seed, message, ctx) — the Magnetar analog of
// Pulsar's Class N1 byte-equality claim.
//
// - Verify: a thin dispatch over circl/slhdsa.Verify. Threshold
// signatures verify under unmodified FIPS 205 verifiers.
//
// HONEST TRUST CAVEAT (v0.1): the aggregator process is in the
// trusted computing base for the brief window the master seed is
// reconstructed in memory. This is the same trust model as
// Pulsar's v0.1 reveal-and-aggregate (BLOCKERS.md). Operational
// mitigations: run the aggregator in a TEE, mlock the seed
// buffer, disable ptrace, and use a short-lived signer process.
// See DEPLOYMENT-RUNBOOK.md for the deployment-time mitigations
// matrix and SPEC.md for the full trust-model disclosure.
//
// A v0.2 instantiation that gives true threshold secrecy
// (aggregator never sees the seed) is on the research path — full
// MPC over SLH-DSA's hash tree is the candidate construction; see
// BLOCKERS.md BLK-1.
package magnetar
+220
View File
@@ -0,0 +1,220 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// kat_test.go — KAT replay tests. Loads the JSON vectors emitted
// by cmd/genkat and checks that the package implementation
// reproduces the bytes verbatim.
//
// On a clean checkout: re-running cmd/genkat must produce the
// same JSON output, and these tests must continue to pass.
import (
"bytes"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"testing"
)
type katKeygen struct {
Mode string `json:"mode"`
Seed string `json:"seed"`
PublicKey string `json:"public_key"`
PrivateKey string `json:"private_key"`
}
type katSign struct {
Mode string `json:"mode"`
Seed string `json:"seed"`
Message string `json:"message"`
Context string `json:"context"`
Signature string `json:"signature"`
}
type katVerify struct {
Mode string `json:"mode"`
PublicKey string `json:"public_key"`
Message string `json:"message"`
Context string `json:"context"`
Signature string `json:"signature"`
Valid bool `json:"valid"`
}
type katThreshold struct {
Mode string `json:"mode"`
N int `json:"n"`
T int `json:"t"`
Message string `json:"message"`
PublicKey string `json:"public_key"`
Signature string `json:"signature"`
Quorum []string `json:"quorum"`
SessionID string `json:"session_id"`
Attempt uint32 `json:"attempt"`
}
type katDKG struct {
Mode string `json:"mode"`
N int `json:"n"`
T int `json:"t"`
Committee []string `json:"committee"`
PublicKey string `json:"public_key"`
TranscriptHash string `json:"transcript_hash"`
Shares []string `json:"shares"`
}
// vectorsDir returns the path to the KAT vectors directory.
// Resolved relative to the package directory.
func vectorsDir() string {
return filepath.Join("..", "..", "..", "..", "vectors")
}
// modeFromName converts a KAT mode string back to the Mode enum.
func modeFromName(name string) Mode {
switch name {
case "Magnetar-SHAKE-192s":
return ModeM192s
case "Magnetar-SHAKE-192f":
return ModeM192f
case "Magnetar-SHAKE-256s":
return ModeM256s
default:
return ModeUnspecified
}
}
// hexDecode is a test-side hex.DecodeString that fatals on error.
func hexDecode(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatalf("hex decode: %v", err)
}
return b
}
// loadVectors reads a JSON KAT file into v. Skips the test (with
// a message) if the file does not exist — vectors are checked in
// via cmd/genkat and may be absent on a fresh checkout before
// genkat is run.
func loadVectors(t *testing.T, path string, v any) bool {
t.Helper()
full := filepath.Join(vectorsDir(), path)
data, err := os.ReadFile(full)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("vectors file not present (%s); run cmd/genkat first", full)
return false
}
t.Fatalf("read %s: %v", full, err)
}
if err := json.Unmarshal(data, v); err != nil {
t.Fatalf("unmarshal %s: %v", full, err)
}
return true
}
func TestKAT_Keygen(t *testing.T) {
skipUnderRace(t)
var katsArr []katKeygen
if !loadVectors(t, "keygen.json", &katsArr) {
return
}
if len(katsArr) == 0 {
t.Fatalf("no keygen vectors")
}
for _, k := range katsArr {
k := k
if testing.Short() && k.Mode != "Magnetar-SHAKE-192s" {
continue // skip non-recommended modes under -short -race
}
t.Run(k.Mode+"_seed_"+k.Seed[:8], func(t *testing.T) {
mode := modeFromName(k.Mode)
params := MustParamsFor(mode)
seed := hexDecode(t, k.Seed)
sk, err := KeyFromSeed(params, seed)
if err != nil {
t.Fatalf("KeyFromSeed: %v", err)
}
expectPub := hexDecode(t, k.PublicKey)
if !bytes.Equal(sk.Pub.Bytes, expectPub) {
t.Errorf("public key bytes differ\nexpected: %x\ngot: %x", expectPub, sk.Pub.Bytes)
}
expectPriv := hexDecode(t, k.PrivateKey)
if !bytes.Equal(sk.Bytes, expectPriv) {
t.Errorf("private key bytes differ\nexpected: %x\ngot: %x", expectPriv, sk.Bytes)
}
})
}
}
func TestKAT_Sign(t *testing.T) {
skipUnderRace(t)
var katsArr []katSign
if !loadVectors(t, "sign.json", &katsArr) {
return
}
for _, k := range katsArr {
k := k
if testing.Short() && k.Mode != "Magnetar-SHAKE-192s" {
continue
}
t.Run(k.Mode+"_seed_"+k.Seed[:8], func(t *testing.T) {
mode := modeFromName(k.Mode)
params := MustParamsFor(mode)
seed := hexDecode(t, k.Seed)
sk, _ := KeyFromSeed(params, seed)
msg := hexDecode(t, k.Message)
ctx := hexDecode(t, k.Context)
sig, err := Sign(params, sk, msg, ctx, false, nil)
if err != nil {
t.Fatalf("Sign: %v", err)
}
expect := hexDecode(t, k.Signature)
if !bytes.Equal(sig.Bytes, expect) {
// Show first divergence.
for i := 0; i < len(sig.Bytes) && i < len(expect); i++ {
if sig.Bytes[i] != expect[i] {
t.Errorf("first divergent byte at offset %d: got 0x%02x expected 0x%02x", i, sig.Bytes[i], expect[i])
break
}
}
t.Fatalf("signature bytes differ from KAT")
}
})
}
}
func TestKAT_Verify(t *testing.T) {
skipUnderRace(t)
var katsArr []katVerify
if !loadVectors(t, "verify.json", &katsArr) {
return
}
for i, k := range katsArr {
k := k
if testing.Short() && k.Mode != "Magnetar-SHAKE-192s" {
continue
}
t.Run(k.Mode+"_case"+itoa(i), func(t *testing.T) {
mode := modeFromName(k.Mode)
params := MustParamsFor(mode)
pub := &PublicKey{Mode: mode, Bytes: hexDecode(t, k.PublicKey)}
msg := hexDecode(t, k.Message)
ctx := hexDecode(t, k.Context)
sig := &Signature{Mode: mode, Bytes: hexDecode(t, k.Signature)}
err := VerifyCtx(params, pub, msg, ctx, sig)
if k.Valid {
if err != nil {
t.Fatalf("expected valid, got error: %v", err)
}
} else {
if err == nil {
t.Fatalf("expected invalid, got nil error")
}
}
})
}
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// keygen.go — single-party key generation. This is the FIPS 205
// keygen path; the threshold-DKG distributed counterpart lives in
// dkg.go.
//
// Output: a FIPS 205 SLH-DSA key pair. The public key is byte-equal
// to what circl's slhdsa.Scheme().DeriveKey would emit on the same
// seed; the private key carries the seed alongside the packed key
// so the threshold layer can reproduce shares deterministically.
//
// The seed is the variable-length (4n bytes) scheme-seed used by
// circl's DeriveKey — for the recommended SHAKE-192s mode that's
// 96 bytes. Magnetar Shamir-shares this seed across the committee
// at DKG time.
import (
"crypto/rand"
"errors"
"io"
"github.com/cloudflare/circl/sign/slhdsa"
)
// Errors returned by keygen and the surrounding key-handling layer.
var (
ErrShortRand = errors.New("magnetar: short read from entropy source")
ErrSeedSize = errors.New("magnetar: seed-derived keygen requires correct seed length")
ErrModeMismatch = errors.New("magnetar: mode mismatch")
ErrInvalidPubKey = errors.New("magnetar: invalid public-key length")
ErrInvalidPriv = errors.New("magnetar: invalid private-key length")
)
// GenerateKey produces a fresh single-party Magnetar key pair. The
// generated key is a FIPS 205 SLH-DSA key pair: the public key
// verifies under unmodified slhdsa.Verify.
//
// rng may be nil; if so crypto/rand.Reader is used. Pass a
// deterministic reader (e.g. bytes.NewReader of a fixed seed) for
// KAT-reproducible key generation.
func GenerateKey(params *Params, rng io.Reader) (*PrivateKey, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if rng == nil {
rng = rand.Reader
}
seed := make([]byte, params.SeedSize)
if _, err := io.ReadFull(rng, seed); err != nil {
return nil, ErrShortRand
}
return KeyFromSeed(params, seed)
}
// KeyFromSeed deterministically derives a Magnetar key pair from a
// params.SeedSize-byte seed. This is the canonical seed-based
// keygen path used by every KAT and by the DKG aggregation step.
//
// The seed is copied into the returned PrivateKey so callers can
// zeroize their input buffer immediately on return.
func KeyFromSeed(params *Params, seed []byte) (*PrivateKey, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if len(seed) != params.SeedSize {
return nil, ErrSeedSize
}
pubBytes, privBytes, err := slhDSAKeyFromSeed(params.ID, seed)
if err != nil {
return nil, err
}
pub := &PublicKey{Mode: params.Mode, Bytes: pubBytes}
seedCopy := make([]byte, len(seed))
copy(seedCopy, seed)
return &PrivateKey{
Mode: params.Mode,
Bytes: privBytes,
Seed: seedCopy,
Pub: pub,
}, nil
}
// slhDSAKeyFromSeed dispatches to circl's SLH-DSA scheme.DeriveKey
// for the given ID and returns packed (pub, priv). This is the
// only place in the package that touches circl's FIPS 205
// implementation for key generation.
func slhDSAKeyFromSeed(id slhdsa.ID, seed []byte) ([]byte, []byte, error) {
scheme := id.Scheme()
if len(seed) != scheme.SeedSize() {
return nil, nil, ErrSeedSize
}
pubKey, privKey := scheme.DeriveKey(seed)
pubBytes, err := pubKey.MarshalBinary()
if err != nil {
return nil, nil, err
}
privBytes, err := privKey.MarshalBinary()
if err != nil {
return nil, nil, err
}
return pubBytes, privBytes, nil
}
// Public returns this key's public companion.
func (sk *PrivateKey) Public() *PublicKey {
return sk.Pub
}
// Equal reports whether two public keys are byte-equal under the
// same mode. Constant-time per byte.
func (p *PublicKey) Equal(other *PublicKey) bool {
if p == nil || other == nil {
return false
}
if p.Mode != other.Mode {
return false
}
if len(p.Bytes) != len(other.Bytes) {
return false
}
var diff byte
for i := range p.Bytes {
diff |= p.Bytes[i] ^ other.Bytes[i]
}
return diff == 0
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
import (
"bytes"
"testing"
)
func TestKeyFromSeed_Deterministic(t *testing.T) {
skipUnderRace(t)
modes := []Mode{ModeM192s, ModeM192f, ModeM256s}
if testing.Short() {
modes = []Mode{ModeM192s}
}
for _, mode := range modes {
mode := mode
t.Run(mode.String(), func(t *testing.T) {
params := MustParamsFor(mode)
seed := make([]byte, params.SeedSize)
for i := range seed {
seed[i] = byte(i)
}
sk1, err := KeyFromSeed(params, seed)
if err != nil {
t.Fatalf("KeyFromSeed#1: %v", err)
}
sk2, err := KeyFromSeed(params, seed)
if err != nil {
t.Fatalf("KeyFromSeed#2: %v", err)
}
if !bytes.Equal(sk1.Bytes, sk2.Bytes) {
t.Fatalf("private keys differ across deterministic calls")
}
if !bytes.Equal(sk1.Pub.Bytes, sk2.Pub.Bytes) {
t.Fatalf("public keys differ across deterministic calls")
}
if !sk1.Pub.Equal(sk2.Pub) {
t.Fatalf("PublicKey.Equal disagrees with byte equality")
}
if len(sk1.Pub.Bytes) != params.PublicKeySize {
t.Fatalf("public key size: got %d want %d", len(sk1.Pub.Bytes), params.PublicKeySize)
}
if len(sk1.Bytes) != params.PrivateKeySize {
t.Fatalf("private key size: got %d want %d", len(sk1.Bytes), params.PrivateKeySize)
}
})
}
}
func TestKeyFromSeed_WrongSize(t *testing.T) {
params := MustParamsFor(ModeM192s)
short := make([]byte, params.SeedSize-1)
if _, err := KeyFromSeed(params, short); err == nil {
t.Fatalf("expected error for short seed")
}
long := make([]byte, params.SeedSize+1)
if _, err := KeyFromSeed(params, long); err == nil {
t.Fatalf("expected error for long seed")
}
}
func TestGenerateKey_FromRNG(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
rng := deterministicReader([]byte("magnetar-generatekey-test"))
sk, err := GenerateKey(params, rng)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if len(sk.Pub.Bytes) != params.PublicKeySize {
t.Fatalf("public key size: got %d want %d", len(sk.Pub.Bytes), params.PublicKeySize)
}
if len(sk.Seed) != params.SeedSize {
t.Fatalf("seed length: got %d want %d", len(sk.Seed), params.SeedSize)
}
}
func TestParams_Validate(t *testing.T) {
for _, mode := range []Mode{ModeM192s, ModeM192f, ModeM256s} {
p := MustParamsFor(mode)
if err := p.Validate(); err != nil {
t.Fatalf("%s: %v", mode, err)
}
}
// Tampered params should fail.
tampered := *MustParamsFor(ModeM192s)
tampered.SeedSize = 1
if err := tampered.Validate(); err == nil {
t.Fatalf("expected tampered params to fail validation")
}
if err := (*Params)(nil).Validate(); err == nil {
t.Fatalf("expected nil params to fail validation")
}
bogus := &Params{Mode: ModeUnspecified}
if err := bogus.Validate(); err == nil {
t.Fatalf("expected unspecified mode to fail validation")
}
}
@@ -0,0 +1,260 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// n1_byte_equality_test.go — empirical validation of the Magnetar
// Class-N1-analog output-interchangeability claim.
//
// The claim:
//
// For any honest threshold-sign session whose share set
// reconstructs to a centralized FIPS 205 seed S, the resulting
// signature bytes are IDENTICAL to those produced by FIPS 205
// SignDeterministic(DeriveKey(S), msg, ctx) — i.e., threshold-
// sign is output-byte-equivalent to single-party deterministic
// FIPS 205 signing under the corresponding centralized key.
//
// This is the load-bearing property of Magnetar's threshold mode:
// any stock SLH-DSA verifier accepts threshold-produced
// signatures with NO code change, because the bytes look exactly
// like a centralized FIPS 205 signature.
//
// The implementation strategy that makes this property hold is
// "centralized recovery": Combine reconstructs the master seed
// via Lagrange interpolation of the share set, derives the
// centralized FIPS 205 SK via KeyFromSeed, and calls
// SignDeterministic directly. See combine.go.
//
// This test performs an end-to-end byte-equality check:
//
// 1. Run the DKG to produce shares + group public key.
// 2. Reconstruct the master seed from a threshold quorum of
// shares using the SAME Lagrange + cSHAKE256 mix as Combine.
// 3. Derive the centralized SK via KeyFromSeed(masterSeed).
// 4. Sign centrally with deterministic FIPS 205 mode.
// 5. Run the full threshold-sign protocol on the same (sid,
// attempt, msg, ctx, quorum) under the same shares.
// 6. Assert that the two signatures are BYTE-IDENTICAL.
//
// Catches: any divergence between the centralized FIPS 205 path
// and the threshold-sign path — wrong Shamir coefficient, wrong
// committee root, wrong cSHAKE256 mix input layout, wrong sign-call
// arguments, etc. A regression on any of these would cause the
// signatures to differ and the test to fail.
import (
"bytes"
"testing"
)
// reconstructMasterSeed Lagrange-reconstructs the DKG master seed
// from a quorum of KeyShares + the full committee membership.
// This mirrors the in-Combine reconstruction at combine.go
// bit-for-bit — same shamirReconstructGF, same byteSum byte
// ordering, same committeeRoot, same cSHAKE256 tag.
func reconstructMasterSeed(t *testing.T, params *Params, quorum []*KeyShare, committee []*KeyShare) []byte {
t.Helper()
shares := make([]shamirShare, len(quorum))
for i, ks := range quorum {
shares[i] = shareFromBytes(ks.EvalPoint, ks.Share)
}
byteSum, err := shamirReconstructGF(shares, params.SeedSize)
if err != nil {
t.Fatal(err)
}
committeeRoot := committeeRootFromShares(committee)
byteSumBytes := make([]byte, params.SeedSize*2)
for b := 0; b < params.SeedSize; b++ {
byteSumBytes[2*b] = byte(byteSum[b] >> 8)
byteSumBytes[2*b+1] = byte(byteSum[b])
}
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
return cshake256(mixInput, params.SeedSize, tagSeedShare)
}
func TestN1_ByteEquality_ThresholdMatchesCentralized(t *testing.T) {
skipUnderRace(t)
// Three configurations spanning small / typical / larger
// thresholds. Every config must produce byte-identical
// signatures from the two paths.
//
// SLH-DSA-SHAKE-192s key derivation is hash-tree-deep
// (h=63, d=7) so KAT-sized DKG runs take several seconds
// under the race detector. We keep the small config always
// and gate the larger configs behind !testing.Short() so
// `go test -race -short` finishes in ~minute.
smallConfigs := []struct {
name string
n, t int
}{
{"3of2", 3, 2},
}
largeConfigs := []struct {
name string
n, t int
}{
{"5of3", 5, 3},
{"7of4", 7, 4},
}
configs := smallConfigs
if !testing.Short() {
configs = append(configs, largeConfigs...)
}
for _, tc := range configs {
tc := tc
t.Run(tc.name, func(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(tc.n)
pub, shares, _ := runDKG(t, params, committee, tc.t)
msg := []byte("Magnetar Class-N1-analog byte-equality test")
var ctx []byte // empty context
// --- Threshold-sign path. ---
var sid [16]byte
copy(sid[:], "n1-byte-eq-sid01")
attempt := uint32(1)
quorumIDs := make([]NodeID, tc.t)
for i := 0; i < tc.t; i++ {
quorumIDs[i] = shares[i].NodeID
}
signers := make([]*ThresholdSigner, tc.t)
for i := 0; i < tc.t; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorumIDs, shares[i], msg,
newDetReader([]byte{byte(i), 0xBE, 0xEF}))
if err != nil {
t.Fatalf("NewThresholdSigner party %d: %v", i, err)
}
signers[i] = s
}
r1 := make([]*Round1Message, tc.t)
for i, s := range signers {
m, err := s.Round1()
if err != nil {
t.Fatalf("Round1 party %d: %v", i, err)
}
r1[i] = m
}
r2 := make([]*Round2Message, tc.t)
for i, s := range signers {
m, _, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2 party %d: %v", i, err)
}
r2[i] = m
}
thresholdSig, err := Combine(params, pub, msg, ctx, false, sid, attempt, quorumIDs, tc.t, r1, r2, shares)
if err != nil {
t.Fatalf("Combine: %v", err)
}
// --- Centralized path. ---
quorumShares := make([]*KeyShare, tc.t)
for i := 0; i < tc.t; i++ {
quorumShares[i] = shares[i]
}
masterSeed := reconstructMasterSeed(t, params, quorumShares, shares)
// Sanity: master_seed must derive to the same pubkey as DKG produced.
sk, err := KeyFromSeed(params, masterSeed)
if err != nil {
t.Fatalf("KeyFromSeed: %v", err)
}
if !sk.Pub.Equal(pub) {
t.Fatalf("reconstructed master seed does not derive to DKG group pubkey")
}
centralSig, err := Sign(params, sk, msg, ctx, false, nil)
if err != nil {
t.Fatalf("Sign (central): %v", err)
}
// --- Byte equality. ---
if !bytes.Equal(thresholdSig.Bytes, centralSig.Bytes) {
t.Errorf("threshold and centralized signature bytes differ\n"+
"threshold: %x\ncentral: %x", thresholdSig.Bytes[:32], centralSig.Bytes[:32])
// Find the first divergent byte.
for i := 0; i < len(thresholdSig.Bytes) && i < len(centralSig.Bytes); i++ {
if thresholdSig.Bytes[i] != centralSig.Bytes[i] {
t.Errorf("first divergent byte at offset %d: threshold=0x%02x central=0x%02x",
i, thresholdSig.Bytes[i], centralSig.Bytes[i])
break
}
}
t.Fatalf("byte-equality theorem violated")
}
// Both signatures must verify under unmodified FIPS 205.
if err := Verify(params, pub, msg, thresholdSig); err != nil {
t.Fatalf("threshold sig fails FIPS 205 Verify: %v", err)
}
if err := Verify(params, pub, msg, centralSig); err != nil {
t.Fatalf("central sig fails FIPS 205 Verify: %v", err)
}
})
}
}
func TestN1_ByteEquality_DifferentQuorumsSameSignature(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: SLH-DSA-SHAKE-192s DKG with n=5 is too slow under -short")
}
// Test that two DISTINCT quorums (different subsets of the
// share set) produce the SAME signature for the same
// (message, ctx). This is a direct consequence of
// reveal-and-aggregate's seed reconstruction being quorum-
// independent.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(5)
pub, shares, _ := runDKG(t, params, committee, 3)
msg := []byte("Magnetar quorum-independence test")
var ctx []byte
signWith := func(idxs []int, seedTag byte) []byte {
t.Helper()
var sid [16]byte
copy(sid[:], "magnetar-multi-q")
quorum := make([]NodeID, len(idxs))
quorumShares := make([]*KeyShare, len(idxs))
for i, j := range idxs {
quorum[i] = shares[j].NodeID
quorumShares[i] = shares[j]
}
signers := make([]*ThresholdSigner, len(idxs))
for i, j := range idxs {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[j], msg,
newDetReader([]byte{byte(i), seedTag}))
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, ctx, false, sid, 1, quorum, len(idxs), r1, r2, shares)
if err != nil {
t.Fatalf("Combine: %v", err)
}
return sig.Bytes
}
sigA := signWith([]int{0, 1, 2}, 0xAA)
sigB := signWith([]int{2, 3, 4}, 0xBB)
if !bytes.Equal(sigA, sigB) {
t.Errorf("different quorums produced different signatures (expected equal under reveal-and-aggregate)")
// First divergent byte for debugging.
for i := 0; i < len(sigA) && i < len(sigB); i++ {
if sigA[i] != sigB[i] {
t.Errorf("first divergent byte at offset %d: A=0x%02x B=0x%02x", i, sigA[i], sigB[i])
break
}
}
t.Fatalf("quorum-independence violated")
}
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// params.go — concrete parameter sets for Magnetar and the canonical
// Mode -> Params resolution. Magnetar instantiates threshold SLH-DSA
// over the FIPS 205 parameter sets defined in NIST FIPS 205 §10.1
// Table 2.
//
// The v0.1 reveal-and-aggregate construction shares the 4n-byte
// SLH-DSA scheme seed (the input to slhdsa.Scheme().DeriveKey) via
// byte-wise Shamir secret sharing over GF(257). On Combine the seed
// is reconstructed in aggregator memory, fed to the single-party
// FIPS 205 SignDeterministic, and zeroized. The resulting signature
// is byte-identical to single-party SLH-DSA on the same (seed, msg,
// ctx) — this is the Magnetar analog of Pulsar's Class N1 claim.
//
// The v0.1 honest trust caveat: the aggregator process is TCB for
// the brief window the master seed is reconstructed. See SPEC.md
// "Trust model" and DEPLOYMENT-RUNBOOK.md.
import (
"errors"
"github.com/cloudflare/circl/sign/slhdsa"
)
// Mode identifies which FIPS 205 parameter set a Magnetar instance
// targets. The v0.1 implementation ships the SHAKE-family small and
// fast variants at NIST PQ category 3 (≥192-bit security) and
// category 5 (≥256-bit security).
//
// The SHAKE family is preferred over the SHA-2 family for two
// reasons: (i) FIPS 202 SHA-3 + cSHAKE is the same hash family used
// by every other Magnetar transcript hash, keeping the audit
// footprint single-suite; (ii) the FIPS 205 SHAKE primitive permits
// constant-time implementations on the same SHA-3 core that Pulsar
// uses, sharing the side-channel analysis.
type Mode uint8
const (
// ModeUnspecified rejects every operation; included so the zero
// Mode is invalid (forces explicit initialization).
ModeUnspecified Mode = 0
// ModeM192s targets FIPS 205 SLH-DSA-SHAKE-192s (NIST PQ
// Category 3, ≥192-bit classical security). 48-byte pk,
// 96-byte sk, 16224-byte signature. RECOMMENDED production
// target.
ModeM192s Mode = 1
// ModeM192f targets FIPS 205 SLH-DSA-SHAKE-192f (Category 3,
// "fast" variant: smaller hypertree → larger signature, faster
// signing). 48-byte pk, 96-byte sk, 35664-byte signature.
ModeM192f Mode = 2
// ModeM256s targets FIPS 205 SLH-DSA-SHAKE-256s (NIST PQ
// Category 5, ≥256-bit classical security). 64-byte pk,
// 128-byte sk, 29792-byte signature.
ModeM256s Mode = 3
)
// String returns the canonical mode name.
func (m Mode) String() string {
switch m {
case ModeM192s:
return "Magnetar-SHAKE-192s"
case ModeM192f:
return "Magnetar-SHAKE-192f"
case ModeM256s:
return "Magnetar-SHAKE-256s"
default:
return "Magnetar-unspecified"
}
}
// Params captures the concrete parameters of a Magnetar instance.
// The values are derived from FIPS 205 §10.1 Table 2.
type Params struct {
Mode Mode
// FIPS 205 hash-tree shape. n is the WOTS+ message length (per
// FIPS 205 §10.1); seed size = 4n bytes (3n internal seeds +
// the public-key root absorbs the same width).
N int // WOTS+ message length in bytes (16 / 24 / 32)
// Wire sizes. Match circl/slhdsa byte-for-byte.
PublicKeySize int
PrivateKeySize int
SignatureSize int
// SeedSize is the byte length of the SLH-DSA scheme seed used by
// circl's DeriveKey. Equal to PrivateKeySize for every FIPS 205
// parameter set.
SeedSize int
// ID is the circl/slhdsa identifier this Mode dispatches to.
// Centralising the mapping makes the dispatch table auditable.
ID slhdsa.ID
// ShamirPrime is the small prime over which per-byte Shamir
// shares of the SLH-DSA seed are computed. 257 matches the
// Pulsar choice: smallest prime > 255, admits every byte as a
// distinct GF element, gives 9-bit shares packed as one uint16
// per byte.
ShamirPrime uint32
}
// SeedSize is the byte length of an SLH-DSA scheme seed for the
// recommended SHAKE-192s mode. Other modes have different seed
// sizes — callers MUST use params.SeedSize, not this constant, for
// non-192s code paths. Provided as a const for KAT generators that
// pin to the recommended mode.
const SeedSize = 96
// MaxCommittee257 is the maximum committee size under byte-wise
// Shamir over GF(257). 256 is exactly q-1 = 257-1, the upper bound
// on distinct non-zero evaluation points.
const MaxCommittee257 = 256
// Hard-coded parameter tables per FIPS 205 §10.1 Table 2.
// ParamsM192s is the Magnetar SHAKE-192s parameter set (recommended).
var ParamsM192s = &Params{
Mode: ModeM192s,
N: 24,
PublicKeySize: 48,
PrivateKeySize: 96,
SignatureSize: 16224,
SeedSize: 96,
ID: slhdsa.SHAKE_192s,
ShamirPrime: 257,
}
// ParamsM192f is the Magnetar SHAKE-192f parameter set.
var ParamsM192f = &Params{
Mode: ModeM192f,
N: 24,
PublicKeySize: 48,
PrivateKeySize: 96,
SignatureSize: 35664,
SeedSize: 96,
ID: slhdsa.SHAKE_192f,
ShamirPrime: 257,
}
// ParamsM256s is the Magnetar SHAKE-256s parameter set.
var ParamsM256s = &Params{
Mode: ModeM256s,
N: 32,
PublicKeySize: 64,
PrivateKeySize: 128,
SignatureSize: 29792,
SeedSize: 128,
ID: slhdsa.SHAKE_256s,
ShamirPrime: 257,
}
// ParamsFor returns the canonical Params for the given Mode.
func ParamsFor(mode Mode) (*Params, error) {
switch mode {
case ModeM192s:
return ParamsM192s, nil
case ModeM192f:
return ParamsM192f, nil
case ModeM256s:
return ParamsM256s, nil
default:
return nil, ErrUnknownMode
}
}
// MustParamsFor is the panic-on-error variant of ParamsFor for use
// in constants and tests. Production code must use ParamsFor.
func MustParamsFor(mode Mode) *Params {
p, err := ParamsFor(mode)
if err != nil {
panic(err)
}
return p
}
// Validate checks that a Params struct is internally consistent and
// matches one of the registered parameter sets.
func (p *Params) Validate() error {
if p == nil {
return ErrNilParams
}
canonical, err := ParamsFor(p.Mode)
if err != nil {
return err
}
if *p != *canonical {
return ErrParamsTampered
}
return nil
}
// Errors returned by params operations.
var (
ErrUnknownMode = errors.New("magnetar: unknown mode")
ErrNilParams = errors.New("magnetar: nil params")
ErrParamsTampered = errors.New("magnetar: params do not match canonical set")
)
+19
View File
@@ -0,0 +1,19 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !race
package magnetar
// race_off_test.go — race-detector-OFF constant. When the binary is
// built without the race detector, this constant is true; otherwise
// the race_on_test.go counterpart sets it to false.
//
// Tests that wrap heavy SLH-DSA computation (DeriveKey,
// SignDeterministic) skip themselves when raceEnabled is true because
// the race detector adds 5-10× overhead, and these tests carry no
// inter-goroutine concurrency — they cannot trigger a race condition
// regardless. Race-detector runs cover the cheap unit tests
// (shamir, transcript, types).
const raceEnabled = false
+12
View File
@@ -0,0 +1,12 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build race
package magnetar
// race_on_test.go — race-detector-ON counterpart of
// race_off_test.go. Sets raceEnabled = true so SLH-DSA-heavy tests
// can self-skip with a one-line t.Skip(skipUnderRace) gate.
const raceEnabled = true
+226
View File
@@ -0,0 +1,226 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// shamir.go — byte-wise Shamir secret sharing over GF(257) for the
// SLH-DSA scheme seed.
//
// Why byte-wise over GF(257): the threshold layer shares a 4n-byte
// SLH-DSA scheme seed (96 bytes for SHAKE-192s, 128 bytes for
// SHAKE-256s). The byte values 0..255 fit in [0, 257) and share
// values fit in [0, 257) which need 9 bits → packs as one uint16
// per per-byte share value. Reconstruction at x=0 returns the
// original byte in [0, 256) (mod 256) because the polynomial's
// constant term is the byte.
//
// The choice of GF(257) keeps the wire-share size minimal: each
// share is seed_size × uint16 bytes, regardless of which SLH-DSA
// parameter set is in use.
//
// All arithmetic is constant-time mod p via the small-prime
// modular inverse.
import "errors"
// shamirPrime is the small prime used for per-byte Shamir sharing.
// 257 is the smallest prime > 255 so it admits every byte value as
// a distinct field element.
const shamirPrime uint32 = 257
// shamirShare contains one party's per-byte Shamir share of a
// seed_size-byte secret. Each element is a value in [0, 257)
// stored in a uint16 lane. The Y slice has length seed_size.
type shamirShare struct {
X uint32 // Shamir evaluation point in [1, p); 1-indexed
Y []uint16 // per-byte share value at X (each in [0, p))
}
// shamirShareErrs.
var (
ErrInvalidThreshold = errors.New("magnetar: invalid threshold (n < t or t < 1)")
ErrCommitteeTooLarge = errors.New("magnetar: committee larger than 256 parties (shamir GF(257))")
ErrNotEnoughShares = errors.New("magnetar: not enough shares for reconstruction")
ErrZeroEvalPoint = errors.New("magnetar: evaluation point x=0 is reserved for the secret")
ErrDuplicateEvalPoint = errors.New("magnetar: duplicate evaluation point in shares")
ErrShareWireSize = errors.New("magnetar: share has wrong wire size")
)
// shamirDealRandom shares a seed_size-byte secret across n parties
// with reconstruction threshold t.
//
// Thin wrapper around shamirDealRandomGF that lifts each byte
// secret[b] to a GF(257) value. Used by the DKG path where each
// party's contribution IS a seed_size-byte vector.
func shamirDealRandom(secret []byte, n, t int, coeffStream []byte) ([]shamirShare, error) {
gf := make([]uint16, len(secret))
for b := 0; b < len(secret); b++ {
gf[b] = uint16(secret[b])
}
return shamirDealRandomGF(gf, n, t, coeffStream)
}
// shamirDealRandomGF shares a seedSize-element GF(257) secret
// vector across n parties with reconstruction threshold t. Each
// party 1..n gets a share at evaluation point i. The (t-1)
// polynomial coefficients per slot are pulled from coeffStream.
//
// If coeffStream is shorter than needed, it is stretched via
// cSHAKE256(coeffStream, tag=MAGNETAR-SEED-SHARE-V1).
func shamirDealRandomGF(secret []uint16, n, t int, coeffStream []byte) ([]shamirShare, error) {
if t < 1 || n < t {
return nil, ErrInvalidThreshold
}
if n > MaxCommittee257 {
return nil, ErrCommitteeTooLarge
}
seedSize := len(secret)
needed := (t - 1) * seedSize * 2
if needed < 2 {
needed = 2
}
if len(coeffStream) < needed {
coeffStream = cshake256(coeffStream, needed, tagSeedShare)
}
// coeffs[d][b] is the degree-d coefficient for slot b.
// coeffs[0][b] is the constant term — the secret value itself.
coeffs := make([][]uint16, t)
for d := 0; d < t; d++ {
coeffs[d] = make([]uint16, seedSize)
}
for b := 0; b < seedSize; b++ {
coeffs[0][b] = secret[b] % uint16(shamirPrime)
}
off := 0
for d := 1; d < t; d++ {
for b := 0; b < seedSize; b++ {
r := uint32(coeffStream[off])<<8 | uint32(coeffStream[off+1])
off += 2
coeffs[d][b] = uint16(r % shamirPrime)
}
}
shares := make([]shamirShare, n)
for i := 1; i <= n; i++ {
shares[i-1].X = uint32(i)
shares[i-1].Y = make([]uint16, seedSize)
x := uint32(i)
for b := 0; b < seedSize; b++ {
// Horner's method.
acc := uint32(coeffs[t-1][b])
for d := t - 2; d >= 0; d-- {
acc = (acc*x + uint32(coeffs[d][b])) % shamirPrime
}
shares[i-1].Y[b] = uint16(acc)
}
}
return shares, nil
}
// shamirReconstructGF Lagrange-interpolates the constant term as a
// seedSize-element GF(257) vector. Used by Combine to preserve
// byte-value 256 across reconstruction; the caller routes the
// 16-bit GF result through a cSHAKE256 mix that absorbs the 256/0
// distinction.
func shamirReconstructGF(shares []shamirShare, seedSize int) ([]uint16, error) {
out := make([]uint16, seedSize)
if len(shares) < 1 {
return out, ErrNotEnoughShares
}
seen := make(map[uint32]struct{}, len(shares))
for _, s := range shares {
if s.X == 0 {
return out, ErrZeroEvalPoint
}
if _, dup := seen[s.X]; dup {
return out, ErrDuplicateEvalPoint
}
seen[s.X] = struct{}{}
if len(s.Y) != seedSize {
return out, ErrShareWireSize
}
}
t := len(shares)
// Lagrange basis values at x=0 over GF(p).
// λ_i = Π_{j≠i} (-x_j) / (x_i - x_j) mod p
lambdas := make([]uint16, t)
for i := 0; i < t; i++ {
num := uint32(1)
den := uint32(1)
for j := 0; j < t; j++ {
if i == j {
continue
}
negXj := shamirPrime - (shares[j].X % shamirPrime)
num = (num * negXj) % shamirPrime
diff := (shamirPrime + shares[i].X - shares[j].X) % shamirPrime
den = (den * diff) % shamirPrime
}
denInv := modInvSmall(den, shamirPrime)
lambdas[i] = uint16((num * denInv) % shamirPrime)
}
for b := 0; b < seedSize; b++ {
var acc uint32
for i := 0; i < t; i++ {
acc = (acc + uint32(lambdas[i])*uint32(shares[i].Y[b])) % shamirPrime
}
out[b] = uint16(acc)
}
return out, nil
}
// modInvSmall computes the modular inverse of a mod p where p is a
// small prime. Uses Fermat's little theorem (a^(p-2) ≡ a^-1 mod p).
// p must be prime and a must be in [1, p).
func modInvSmall(a, p uint32) uint32 {
return modPowSmall(a, p-2, p)
}
// modPowSmall computes (base^exp) mod p via square-and-multiply.
func modPowSmall(base, exp, p uint32) uint32 {
result := uint32(1)
b := base % p
for exp > 0 {
if exp&1 == 1 {
result = (result * b) % p
}
b = (b * b) % p
exp >>= 1
}
return result
}
// shareToBytes serialises a shamirShare's Y component to wire form
// (big-endian uint16 per byte position).
func shareToBytes(s shamirShare) []byte {
out := make([]byte, len(s.Y)*2)
for b := 0; b < len(s.Y); b++ {
out[2*b] = byte(s.Y[b] >> 8)
out[2*b+1] = byte(s.Y[b])
}
return out
}
// shareFromBytes deserialises a wire-form Y component.
func shareFromBytes(x uint32, buf []byte) shamirShare {
seedSize := len(buf) / 2
s := shamirShare{X: x, Y: make([]uint16, seedSize)}
for b := 0; b < seedSize; b++ {
s.Y[b] = uint16(buf[2*b])<<8 | uint16(buf[2*b+1])
}
return s
}
// EvalPointFromID derives a deterministic non-zero Shamir
// evaluation point in [1, 257) from a NodeID. Used by callers that
// want an ID-stable evaluation point rather than a position-in-
// committee point. The DKG default uses (committee_index + 1)
// which is simpler and KAT-stable.
func EvalPointFromID(id NodeID) uint32 {
digest := cshake256(id[:], 1, tagSeedShare)
return uint32(digest[0])%(shamirPrime-1) + 1
}
+170
View File
@@ -0,0 +1,170 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
import (
"bytes"
"testing"
)
func TestShamir_DealReconstruct_Basic(t *testing.T) {
// Share a 96-byte (SHAKE-192s) seed with (n=5, t=3) and check
// that every 3-element subset reconstructs the secret.
seed := bytes.Repeat([]byte{0xAB}, 96)
for i := range seed {
seed[i] = byte(i ^ 0x55)
}
coeffs := bytes.Repeat([]byte{0xAA}, (3-1)*96*2)
shares, err := shamirDealRandom(seed, 5, 3, coeffs)
if err != nil {
t.Fatalf("shamirDealRandom: %v", err)
}
if len(shares) != 5 {
t.Fatalf("expected 5 shares, got %d", len(shares))
}
// Reconstruct from the first 3.
rec, err := shamirReconstructGF(shares[:3], 96)
if err != nil {
t.Fatalf("shamirReconstructGF: %v", err)
}
for b := 0; b < 96; b++ {
// The byte should be the GF(257) representation of the
// original byte (in [0, 256]). Since every byte was in
// [0, 256), the GF(257) reconstruction must equal the
// byte value.
if rec[b] != uint16(seed[b]) {
t.Fatalf("reconstructed byte %d: got %d want %d", b, rec[b], seed[b])
}
}
}
func TestShamir_ReconstructFromAnySubset(t *testing.T) {
// (n=4, t=3). Any 3 of the 4 shares should reconstruct.
seed := make([]byte, 96)
for i := range seed {
seed[i] = byte(i * 7)
}
coeffs := bytes.Repeat([]byte{0xCD}, (3-1)*96*2)
shares, err := shamirDealRandom(seed, 4, 3, coeffs)
if err != nil {
t.Fatalf("shamirDealRandom: %v", err)
}
subsets := [][]int{
{0, 1, 2},
{0, 1, 3},
{0, 2, 3},
{1, 2, 3},
}
for _, sub := range subsets {
picked := make([]shamirShare, len(sub))
for i, j := range sub {
picked[i] = shares[j]
}
rec, err := shamirReconstructGF(picked, 96)
if err != nil {
t.Fatalf("reconstruct %v: %v", sub, err)
}
for b := 0; b < 96; b++ {
if rec[b] != uint16(seed[b]) {
t.Fatalf("subset %v: byte %d: got %d want %d", sub, b, rec[b], seed[b])
}
}
}
}
func TestShamir_DuplicateEvalPointRejected(t *testing.T) {
seed := make([]byte, 96)
coeffs := bytes.Repeat([]byte{0xEE}, (3-1)*96*2)
shares, _ := shamirDealRandom(seed, 5, 3, coeffs)
dup := []shamirShare{shares[0], shares[0], shares[1]}
if _, err := shamirReconstructGF(dup, 96); err == nil {
t.Fatalf("expected ErrDuplicateEvalPoint")
}
}
func TestShamir_ZeroEvalPointRejected(t *testing.T) {
zero := []shamirShare{
{X: 0, Y: make([]uint16, 96)},
{X: 1, Y: make([]uint16, 96)},
{X: 2, Y: make([]uint16, 96)},
}
if _, err := shamirReconstructGF(zero, 96); err == nil {
t.Fatalf("expected ErrZeroEvalPoint")
}
}
func TestShamir_TooLargeCommitteeRejected(t *testing.T) {
if _, err := shamirDealRandom(make([]byte, 96), 257, 3, nil); err == nil {
t.Fatalf("expected ErrCommitteeTooLarge")
}
}
func TestShamir_ThresholdInvalidRejected(t *testing.T) {
if _, err := shamirDealRandom(make([]byte, 96), 3, 0, nil); err == nil {
t.Fatalf("expected ErrInvalidThreshold (t=0)")
}
if _, err := shamirDealRandom(make([]byte, 96), 3, 4, nil); err == nil {
t.Fatalf("expected ErrInvalidThreshold (t > n)")
}
}
func TestShamir_SeedSize128(t *testing.T) {
// Exercise the 128-byte SHAKE-256s seed path.
seed := make([]byte, 128)
for i := range seed {
seed[i] = byte(i * 13)
}
coeffs := bytes.Repeat([]byte{0x11}, (3-1)*128*2)
shares, err := shamirDealRandom(seed, 5, 3, coeffs)
if err != nil {
t.Fatalf("shamirDealRandom (128B): %v", err)
}
rec, err := shamirReconstructGF(shares[:3], 128)
if err != nil {
t.Fatalf("shamirReconstructGF (128B): %v", err)
}
for b := 0; b < 128; b++ {
if rec[b] != uint16(seed[b]) {
t.Fatalf("byte %d: got %d want %d", b, rec[b], seed[b])
}
}
}
func TestShareWireRoundTrip(t *testing.T) {
seed := make([]byte, 96)
for i := range seed {
seed[i] = byte(i)
}
coeffs := bytes.Repeat([]byte{0x77}, (3-1)*96*2)
shares, _ := shamirDealRandom(seed, 3, 2, coeffs)
for _, s := range shares {
wire := shareToBytes(s)
back := shareFromBytes(s.X, wire)
if back.X != s.X {
t.Fatalf("X round trip: got %d want %d", back.X, s.X)
}
for b := 0; b < 96; b++ {
if back.Y[b] != s.Y[b] {
t.Fatalf("Y[%d] round trip: got %d want %d", b, back.Y[b], s.Y[b])
}
}
}
}
func TestEvalPointFromID_NonZero(t *testing.T) {
// EvalPointFromID must never produce 0 (reserved for the
// secret).
for i := 0; i < 256; i++ {
var id NodeID
id[0] = byte(i)
p := EvalPointFromID(id)
if p == 0 {
t.Fatalf("EvalPointFromID produced 0 for first byte %d", i)
}
if p >= shamirPrime {
t.Fatalf("EvalPointFromID produced %d >= shamirPrime=%d", p, shamirPrime)
}
}
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// sign.go — single-party signing. The single-party path is the
// reference baseline for the threshold path in threshold.go: a
// threshold quorum that successfully completes its rounds emits a
// Signature byte-identical to single-party Sign on the same
// reconstructed seed.
//
// This file uses circl/slhdsa as the FIPS 205 signing primitive.
// The threshold layer above does not depend on internals; it
// depends only on (i) seed-based key derivation matching DeriveKey
// and (ii) the output being a valid FIPS 205 signature.
import (
"crypto/rand"
"errors"
"io"
"github.com/cloudflare/circl/sign/slhdsa"
)
// Errors returned by signing.
var (
ErrCtxTooLong = errors.New("magnetar: context string longer than 255 bytes")
ErrSignFailure = errors.New("magnetar: FIPS 205 signing failed")
ErrNilKey = errors.New("magnetar: nil key")
)
// Sign produces a FIPS 205 SLH-DSA signature on message under the
// supplied private key.
//
// ctx is the FIPS 205 §10.2 context string (0..255 bytes); pass
// nil for the empty context. randomized chooses between the hedged
// and deterministic signing variants — deterministic (false) is
// the KAT-reproducible variant.
//
// If rng is nil and randomized is true, crypto/rand.Reader is used.
// Pass a deterministic reader for KAT reproducibility.
func Sign(params *Params, sk *PrivateKey, message, ctx []byte, randomized bool, rng io.Reader) (*Signature, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if sk == nil {
return nil, ErrNilKey
}
if sk.Mode != params.Mode {
return nil, ErrModeMismatch
}
if len(ctx) > 255 {
return nil, ErrCtxTooLong
}
if rng == nil {
rng = rand.Reader
}
sigBytes, err := slhSign(params.ID, sk.Bytes, message, ctx, randomized, rng)
if err != nil {
return nil, err
}
return &Signature{Mode: params.Mode, Bytes: sigBytes}, nil
}
// SignCtx is the FIPS 205 context-aware Sign convenience wrapper.
// Equivalent to Sign with ctx != nil.
func SignCtx(params *Params, sk *PrivateKey, message, ctx []byte, randomized bool, rng io.Reader) (*Signature, error) {
return Sign(params, sk, message, ctx, randomized, rng)
}
// slhSign dispatches to circl/slhdsa for the given parameter ID
// and returns the packed signature.
//
// circl's slhdsa.SignDeterministic produces signatures that are
// byte-identical across calls for the same (sk, message, ctx).
// SignRandomized adds a fresh n-byte randomness from the supplied
// reader; with a deterministic reader it remains reproducible.
//
// The threshold-Combine path uses randomized=false to guarantee
// byte-identical output to the single-party reference (this is the
// Magnetar analog of Pulsar's Class N1).
func slhSign(id slhdsa.ID, packedSk, message, ctx []byte, randomized bool, rng io.Reader) ([]byte, error) {
priv := slhdsa.PrivateKey{ID: id}
if err := priv.UnmarshalBinary(packedSk); err != nil {
return nil, err
}
msg := slhdsa.NewMessage(message)
if randomized {
return slhdsa.SignRandomized(&priv, rng, msg, ctx)
}
return slhdsa.SignDeterministic(&priv, msg, ctx)
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
import (
"bytes"
"testing"
)
func TestSign_Verify_RoundTrip(t *testing.T) {
skipUnderRace(t)
modes := []Mode{ModeM192s, ModeM192f, ModeM256s}
if testing.Short() {
// 192f signatures are large (35664 bytes) and 256s
// keygen/sign is the slowest variant; keep only the
// recommended 192s mode under -short.
modes = []Mode{ModeM192s}
}
for _, mode := range modes {
mode := mode
t.Run(mode.String(), func(t *testing.T) {
params := MustParamsFor(mode)
seed := bytes.Repeat([]byte{0xA5}, params.SeedSize)
sk, err := KeyFromSeed(params, seed)
if err != nil {
t.Fatalf("KeyFromSeed: %v", err)
}
msg := []byte("Magnetar single-party Sign-Verify round trip")
sig, err := Sign(params, sk, msg, nil, false, nil)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if len(sig.Bytes) != params.SignatureSize {
t.Fatalf("signature size: got %d want %d", len(sig.Bytes), params.SignatureSize)
}
if err := Verify(params, sk.Pub, msg, sig); err != nil {
t.Fatalf("Verify: %v", err)
}
// Tampered: flip a byte in the middle.
bad := append([]byte{}, sig.Bytes...)
bad[len(bad)/2] ^= 0x01
badSig := &Signature{Mode: mode, Bytes: bad}
if err := Verify(params, sk.Pub, msg, badSig); err == nil {
t.Fatalf("Verify accepted tampered signature")
}
// Tampered message.
if err := Verify(params, sk.Pub, []byte("different"), sig); err == nil {
t.Fatalf("Verify accepted on different message")
}
})
}
}
func TestSign_Deterministic_ByteEqual(t *testing.T) {
skipUnderRace(t)
// For randomized=false (SignDeterministic), two calls on the
// same (sk, msg, ctx) MUST produce byte-identical output.
// This is the FIPS 205 invariant Magnetar's threshold path
// rides on top of.
params := MustParamsFor(ModeM192s)
seed := bytes.Repeat([]byte{0x42}, params.SeedSize)
sk, _ := KeyFromSeed(params, seed)
msg := []byte("Magnetar determinism test")
sig1, err := Sign(params, sk, msg, nil, false, nil)
if err != nil {
t.Fatalf("Sign#1: %v", err)
}
sig2, err := Sign(params, sk, msg, nil, false, nil)
if err != nil {
t.Fatalf("Sign#2: %v", err)
}
if !bytes.Equal(sig1.Bytes, sig2.Bytes) {
t.Fatalf("deterministic SignDeterministic produced different bytes on repeat call")
}
}
func TestSign_WithContext(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
seed := bytes.Repeat([]byte{0xC7}, params.SeedSize)
sk, _ := KeyFromSeed(params, seed)
msg := []byte("Magnetar context test")
ctx := []byte("lux-magnetar-ctx-v0.1")
sig, err := SignCtx(params, sk, msg, ctx, false, nil)
if err != nil {
t.Fatalf("SignCtx: %v", err)
}
if err := VerifyCtx(params, sk.Pub, msg, ctx, sig); err != nil {
t.Fatalf("VerifyCtx: %v", err)
}
// Verify under a different context must fail.
if err := VerifyCtx(params, sk.Pub, msg, []byte("wrong-ctx"), sig); err == nil {
t.Fatalf("VerifyCtx accepted wrong context")
}
// Verify with NO context must fail (context binding).
if err := Verify(params, sk.Pub, msg, sig); err == nil {
t.Fatalf("Verify (no ctx) accepted ctx-bound signature")
}
}
func TestSign_TooLongCtx(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
seed := bytes.Repeat([]byte{0x33}, params.SeedSize)
sk, _ := KeyFromSeed(params, seed)
msg := []byte("x")
ctx := make([]byte, 256)
if _, err := SignCtx(params, sk, msg, ctx, false, nil); err == nil {
t.Fatalf("expected ErrCtxTooLong")
}
}
+140
View File
@@ -0,0 +1,140 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// test_helpers_test.go — shared test utilities for deterministic
// RNG and committee construction. Lives under *_test.go so it is
// not compiled into the production binary.
import (
"crypto/sha256"
"io"
"testing"
)
// skipUnderRace is a self-skip guard for tests that wrap heavy
// SLH-DSA computation. Race-detector runs add 5-10× overhead on
// SHAKE-heavy code; these tests carry no inter-goroutine
// concurrency so they cannot trigger a race condition regardless.
// Race-detector runs cover the cheap unit tests (shamir,
// transcript, types) — see CONTRIBUTING.md "Testing under race".
func skipUnderRace(t *testing.T) {
t.Helper()
if raceEnabled {
t.Skip("skip: SLH-DSA-heavy test under race detector (see CONTRIBUTING.md)")
}
}
// detReader is a deterministic SHA-256-counter byte stream from a
// seed. Used by tests + KAT generator to produce reproducible
// "random" input. NOT a CSPRNG suitable for production.
type detReader struct {
seed []byte
buf []byte
off int
}
func newDetReader(seed []byte) *detReader {
return &detReader{seed: seed}
}
func (r *detReader) Read(p []byte) (int, error) {
for n := 0; n < len(p); {
if r.off >= len(r.buf) {
ctr := uint32(len(r.buf) / 32)
r.buf = nil
for i := 0; i < 128; i++ {
h := sha256.New()
h.Write(r.seed)
h.Write([]byte{byte(ctr >> 24), byte(ctr >> 16), byte(ctr >> 8), byte(ctr)})
r.buf = append(r.buf, h.Sum(nil)...)
ctr++
}
r.off = 0
}
c := copy(p[n:], r.buf[r.off:])
n += c
r.off += c
}
return len(p), nil
}
// deterministicReader is a thin alias for newDetReader for test
// code that wants a one-liner.
func deterministicReader(seed []byte) io.Reader {
return newDetReader(seed)
}
// makeCommittee builds a deterministic NodeID list of length n.
func makeCommittee(n int) []NodeID {
out := make([]NodeID, n)
for i := 0; i < n; i++ {
// Use a 1-indexed first byte so NodeIDs are distinct and
// sorted in canonical order.
out[i][0] = byte(i + 1)
// Mix in a tag so test NodeIDs don't accidentally collide
// with KAT NodeIDs.
copy(out[i][1:], []byte("MAGNETAR-TEST"))
}
return out
}
// runDKG runs an honest DKG ceremony for committee+threshold and
// returns the (group pubkey, share-set, transcript-hash). Fails
// the test on any error.
func runDKG(t *testing.T, params *Params, committee []NodeID, threshold int) (*PublicKey, []*KeyShare, [48]byte) {
t.Helper()
n := len(committee)
sessions := make([]*DKGSession, n)
for i := range sessions {
seed := append([]byte("MAGNETAR-DKG-TEST"), []byte{byte(params.Mode), byte(n), byte(threshold), byte(i)}...)
rng := newDetReader(seed)
s, err := NewDKGSession(params, committee, threshold, committee[i], rng)
if err != nil {
t.Fatalf("NewDKGSession[%d]: %v", i, err)
}
sessions[i] = s
}
r1 := make([]*DKGRound1Msg, n)
for i, s := range sessions {
m, err := s.Round1()
if err != nil {
t.Fatalf("Round1[%d]: %v", i, err)
}
r1[i] = m
}
r2 := make([]*DKGRound2Msg, n)
for i, s := range sessions {
m, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2[%d]: %v", i, err)
}
r2[i] = m
}
outs := make([]*DKGOutput, n)
for i, s := range sessions {
o, err := s.Round3(r2)
if err != nil {
t.Fatalf("Round3[%d]: %v", i, err)
}
if o.AbortEvidence != nil {
t.Fatalf("Round3[%d]: unexpected abort: %v", i, o.AbortEvidence)
}
outs[i] = o
}
// Verify all parties agree on the joint group pubkey.
for i := 1; i < n; i++ {
if !outs[0].GroupPubkey.Equal(outs[i].GroupPubkey) {
t.Fatalf("party %d disagrees on group pubkey", i)
}
if outs[0].TranscriptHash != outs[i].TranscriptHash {
t.Fatalf("party %d disagrees on transcript hash", i)
}
}
shares := make([]*KeyShare, n)
for i, o := range outs {
shares[i] = o.SecretShare
}
return outs[0].GroupPubkey, shares, outs[0].TranscriptHash
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// threshold.go — two-round threshold signing (reveal-and-aggregate).
//
// Protocol shape (v0.1):
//
// Round 1 (party i): sample per-round mask r_i (seed_size×2
// bytes). Compute the masked share s'_i = share_i XOR r_i.
// Compute the commit
// D_i = cSHAKE256(r_i || s'_i || tau_1)
// where tau_1 = (sid, kappa, T, i, pk, msg). Broadcast (D_i).
//
// Round 2 (party i): reveal (r_i, masked_share_i) so peers can
// re-derive D_i and the aggregator can XOR them to recover
// share_i.
//
// Combine: aggregator gathers t Round-2 reveals, re-derives
// each party's D_i and checks it equals the Round-1 commit,
// then Lagrange-interpolates the byte-sum at x=0, applies
// the same cSHAKE256 mix as DKG to recover the master seed,
// calls FIPS 205 SignDeterministic. Returns the resulting
// signature.
//
// The byte-equality theorem (Magnetar analog of Pulsar Class N1):
// the returned signature is byte-identical to what
// slhdsa.SignDeterministic(KeyFromSeed(master_seed), msg, ctx)
// would produce, because Combine literally calls that path on
// the same reconstructed master seed.
//
// The honest v0.1 trust caveat: the aggregator process holds the
// reconstructed master seed for the duration of one Sign call,
// then zeroizes it. See SPEC.md "Trust model" and
// DEPLOYMENT-RUNBOOK.md for the operator-facing disclosure.
import (
"crypto/rand"
"errors"
"io"
)
// Errors returned by threshold signing.
var (
ErrNilSession = errors.New("magnetar: nil ThresholdSigner")
ErrEmptyQuorum = errors.New("magnetar: empty signing quorum")
ErrInsufficientQuor = errors.New("magnetar: quorum smaller than threshold")
ErrRound2CommitBad = errors.New("magnetar: Round-2 reveal does not match Round-1 commit")
ErrSessionMismatch = errors.New("magnetar: round messages from different sessions")
ErrAttemptMismatch = errors.New("magnetar: round messages from different rejection-restart attempts")
ErrPubkeyMismatch = errors.New("magnetar: KeyShare public-key does not match")
)
// ThresholdSigner holds one party's state for a 2-round threshold
// sign ceremony.
//
// A ThresholdSigner is single-use: one (sid, attempt) pair per
// instance. The protocol-layer driver allocates a fresh signer
// for each attempt.
type ThresholdSigner struct {
Params *Params
NodeID NodeID
SecretShare *KeyShare
// SessionID uniquely identifies this signature attempt across
// the network.
SessionID [16]byte
// Attempt is a counter that distinguishes retries; for v0.1
// reveal-and-aggregate it's a transcript-bind only (FIPS 205
// SLH-DSA has no rejection-restart, so Attempt is documentary
// in v0.1; it's wired through for parity with Pulsar and to
// support future MPC-style restarts).
Attempt uint32
// Quorum is the t-element signing committee, sorted ascending
// by NodeID.
Quorum []NodeID
// Message is the FIPS 205 message being signed.
Message []byte
// rng is the entropy source for per-round mask r_i.
rng io.Reader
// Internal Round-1 state.
myMask []byte // r_i — 2 × seed_size bytes
myMaskedShare []byte // s'_i = share_i XOR r_i (per byte)
myCommit [32]byte
}
// NewThresholdSigner constructs a new ThresholdSigner for a
// (sessionID, attempt, quorum, message) tuple.
//
// quorum must contain myShare.NodeID. All parties in the quorum
// must share the same group public key (i.e. they completed the
// same DKG).
//
// rng may be nil — crypto/rand is used. Pass a deterministic
// reader for KAT runs.
func NewThresholdSigner(
params *Params,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
myShare *KeyShare,
message []byte,
rng io.Reader,
) (*ThresholdSigner, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if myShare == nil {
return nil, ErrNilKey
}
if myShare.Mode != params.Mode {
return nil, ErrModeMismatch
}
if len(quorum) == 0 {
return nil, ErrEmptyQuorum
}
found := false
for _, q := range quorum {
if q == myShare.NodeID {
found = true
break
}
}
if !found {
return nil, ErrNotInQuorum
}
if rng == nil {
rng = rand.Reader
}
return &ThresholdSigner{
Params: params,
NodeID: myShare.NodeID,
SecretShare: myShare,
SessionID: sessionID,
Attempt: attempt,
Quorum: append([]NodeID{}, quorum...),
Message: append([]byte{}, message...),
rng: rng,
}, nil
}
// Round1 samples the per-round mask, computes the commit, and
// emits the Round-1 broadcast.
func (s *ThresholdSigner) Round1() (*Round1Message, error) {
maskLen := s.Params.SeedSize * 2
if len(s.SecretShare.Share) != maskLen {
return nil, ErrShareWireSize
}
// Sample raw entropy and derive the per-attempt mask via
// cSHAKE256(rngBytes || sid || attempt || NodeID, tagSignMask).
// This way a deterministic RNG that produces the same output
// across two attempts still yields DISTINCT masks per attempt.
rngBytes := make([]byte, maskLen)
if _, err := io.ReadFull(s.rng, rngBytes); err != nil {
return nil, ErrShortRand
}
maskMix := make([]byte, 0, len(rngBytes)+16+4+len(s.NodeID))
maskMix = append(maskMix, rngBytes...)
maskMix = append(maskMix, s.SessionID[:]...)
maskMix = append(maskMix,
byte(s.Attempt>>24), byte(s.Attempt>>16),
byte(s.Attempt>>8), byte(s.Attempt))
maskMix = append(maskMix, s.NodeID[:]...)
s.myMask = cshake256(maskMix, maskLen, tagSignMask)
zeroizeBytes(rngBytes)
zeroizeBytes(maskMix)
// Mask the share byte-by-byte XOR.
s.myMaskedShare = make([]byte, maskLen)
for i := 0; i < maskLen; i++ {
s.myMaskedShare[i] = s.SecretShare.Share[i] ^ s.myMask[i]
}
// Commit D_i = cSHAKE256(mask || masked || tau_1).
tau := transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, s.NodeID, s.SecretShare.Pub, s.Message)
commitInput := make([]byte, 0, len(s.myMask)+len(s.myMaskedShare)+len(tau))
commitInput = append(commitInput, s.myMask...)
commitInput = append(commitInput, s.myMaskedShare...)
commitInput = append(commitInput, tau...)
s.myCommit = transcriptHash32(tagSignR1, commitInput)
return &Round1Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
Commit: s.myCommit,
}, nil
}
// Round2 ingests Round-1 messages and emits the Round-2 reveal
// carrying (r_i, masked_share_i).
func (s *ThresholdSigner) Round2(round1Msgs []*Round1Message) (*Round2Message, *AbortEvidence, error) {
if len(round1Msgs) < 1 {
return nil, nil, ErrEmptyQuorum
}
// Verify session and attempt consistency.
for _, m := range round1Msgs {
if m.SessionID != s.SessionID {
return nil, nil, ErrSessionMismatch
}
if m.Attempt != s.Attempt {
return nil, nil, ErrAttemptMismatch
}
}
// Round-2 reveal: (r_i, masked_share_i). Pack into PartialSig.
revealed := make([]byte, 0, len(s.myMask)+len(s.myMaskedShare))
revealed = append(revealed, s.myMask...)
revealed = append(revealed, s.myMaskedShare...)
return &Round2Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
PartialSig: revealed,
}, nil, nil
}
// Combine is implemented in combine.go.
// transcriptTau1Bytes builds the Round-1 transcript tau_1 = (sid,
// kappa, T, i, pk, mu). tau_1 is bound into every commit so a
// cross-session replay becomes a transcript mismatch.
func transcriptTau1Bytes(sid [16]byte, attempt uint32, quorum []NodeID, sender NodeID, pk *PublicKey, message []byte) []byte {
parts := [][]byte{}
parts = append(parts, sid[:])
parts = append(parts, []byte{byte(attempt >> 24), byte(attempt >> 16), byte(attempt >> 8), byte(attempt)})
for _, q := range quorum {
parts = append(parts, q[:])
}
parts = append(parts, sender[:])
if pk != nil {
parts = append(parts, pk.Bytes)
}
parts = append(parts, message)
out := []byte{}
out = append(out, leftEncode(uint64(len(parts)))...)
for _, p := range parts {
out = append(out, encodeString(p)...)
}
return out
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
import (
"testing"
)
func TestThreshold_BasicSign(t *testing.T) {
skipUnderRace(t)
// (n=3, t=2): sign with the minimum quorum and verify.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("Magnetar threshold-sign basic test")
var sid [16]byte
copy(sid[:], "magnetar-test-1")
attempt := uint32(1)
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0xAB, 0xCD}))
if err != nil {
t.Fatalf("NewThresholdSigner[%d]: %v", i, err)
}
signers[i] = s
}
r1 := make([]*Round1Message, 2)
for i, s := range signers {
m, err := s.Round1()
if err != nil {
t.Fatalf("Round1[%d]: %v", i, err)
}
r1[i] = m
}
r2 := make([]*Round2Message, 2)
for i, s := range signers {
m, _, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2[%d]: %v", i, err)
}
r2[i] = m
}
sig, err := Combine(params, pub, msg, nil, false, sid, attempt, quorum, 2, r1, r2, shares)
if err != nil {
t.Fatalf("Combine: %v", err)
}
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("Verify: %v", err)
}
}
func TestThreshold_VariousConfigurations(t *testing.T) {
skipUnderRace(t)
if testing.Short() {
t.Skip("skip: SLH-DSA threshold-sign with n>=5 is slow under -short")
}
configs := []struct{ N, T int }{
{3, 2}, {5, 3}, {7, 4},
}
for _, cfg := range configs {
cfg := cfg
t.Run(spfNT(cfg.N, cfg.T), func(t *testing.T) {
params := MustParamsFor(ModeM192s)
committee := makeCommittee(cfg.N)
pub, shares, _ := runDKG(t, params, committee, cfg.T)
msg := []byte("multi-config threshold sign")
sig, err := signWithQuorum(t, params, pub, shares, cfg.T, msg)
if err != nil {
t.Fatalf("signWithQuorum: %v", err)
}
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("Verify: %v", err)
}
})
}
}
func TestThreshold_TamperedRound2Rejected(t *testing.T) {
skipUnderRace(t)
// A Round-2 reveal that doesn't match the Round-1 commit
// must be rejected by Combine.
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("tamper test")
var sid [16]byte
copy(sid[:], "tamper-sid-0001")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, _ := NewThresholdSigner(params, sid, 1, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0xFA, 0xCE}))
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)
}
// Tamper: flip a bit in r2[0]'s mask half.
r2[0].PartialSig[0] ^= 0x01
if _, err := Combine(params, pub, msg, nil, false, sid, 1, quorum, 2, r1, r2, shares); err == nil {
t.Fatalf("Combine accepted tampered Round-2 reveal")
}
}
func TestThreshold_SessionMismatchRejected(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
pub, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("session-mismatch test")
var sid1, sid2 [16]byte
copy(sid1[:], "sid-1234567890ab")
copy(sid2[:], "sid-DIFFERENT001")
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID}
signers := make([]*ThresholdSigner, 2)
for i := 0; i < 2; i++ {
s, _ := NewThresholdSigner(params, sid1, 1, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0xBA, 0xDD}))
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)
}
// Combine with sid2: must fail.
if _, err := Combine(params, pub, msg, nil, false, sid2, 1, quorum, 2, r1, r2, shares); err == nil {
t.Fatalf("Combine accepted mismatched session ID")
}
}
func TestThreshold_NonMemberQuorumRejected(t *testing.T) {
skipUnderRace(t)
params := MustParamsFor(ModeM192s)
committee := makeCommittee(3)
_, shares, _ := runDKG(t, params, committee, 2)
msg := []byte("non-member test")
var sid [16]byte
copy(sid[:], "nonmember-sid01")
var stranger NodeID
stranger[0] = 0xFF
quorum := []NodeID{stranger, shares[0].NodeID}
// shares[1].NodeID isn't in quorum, but shares[1] is not the
// quorum member here; only check shares[0] participates while
// stranger has no key.
if _, err := NewThresholdSigner(params, sid, 1, quorum, shares[1], msg, nil); err == nil {
t.Fatalf("expected ErrNotInQuorum (shares[1].NodeID not in quorum)")
}
}
// signWithQuorum runs a full ceremony: pick first t shares,
// execute Round 1, Round 2, Combine. Returns the signature.
func signWithQuorum(t *testing.T, params *Params, pub *PublicKey, shares []*KeyShare, threshold int, msg []byte) (*Signature, error) {
t.Helper()
quorum := make([]NodeID, threshold)
for i := 0; i < threshold; i++ {
quorum[i] = shares[i].NodeID
}
var sid [16]byte
copy(sid[:], "magnetar-tquorum")
attempt := uint32(7)
signers := make([]*ThresholdSigner, threshold)
for i := 0; i < threshold; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorum, shares[i], msg,
newDetReader([]byte{byte(i), 0x42}))
if err != nil {
return nil, err
}
signers[i] = s
}
r1 := make([]*Round1Message, threshold)
for i, s := range signers {
m, err := s.Round1()
if err != nil {
return nil, err
}
r1[i] = m
}
r2 := make([]*Round2Message, threshold)
for i, s := range signers {
m, _, err := s.Round2(r1)
if err != nil {
return nil, err
}
r2[i] = m
}
return Combine(params, pub, msg, nil, false, sid, attempt, quorum, threshold, r1, r2, shares)
}
+190
View File
@@ -0,0 +1,190 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// transcript.go — FIPS 202 / SP 800-185 transcript primitives used
// by every Magnetar protocol round.
//
// All hashing in Magnetar routes through this file. Direct use of
// stdlib hashes anywhere else in the package is a CI failure.
//
// The two primitives we vend are:
//
// - cSHAKE256(K, X, L, S) — FIPS 202 §6.3 + SP 800-185 §3
// - KMAC256 (K, X, L, S) — SP 800-185 §4
//
// All Magnetar customisation strings live in this file as named
// constants so the audit footprint of the hash layer is one file.
import (
"encoding/binary"
"golang.org/x/crypto/sha3"
)
// Customisation tags for cSHAKE256/KMAC256. Rotating a tag
// invalidates every test vector pinned at that tag.
const (
tagDKGCommit = "MAGNETAR-DKG-COMMIT-V1"
tagDKGTranscript = "MAGNETAR-DKG-TRANSCRIPT-V1"
tagSignR1 = "MAGNETAR-SIGN-R1-V1"
tagSignR1MAC = "MAGNETAR-SIGN-R1-MAC-V1"
tagSignMask = "MAGNETAR-SIGN-MASK-V1"
tagSeedShare = "MAGNETAR-SEED-SHARE-V1"
tagDomainSep = "lux-magnetar-v0.1"
)
// functionName is the SP 800-185 cSHAKE function-name parameter.
// All Magnetar cSHAKE calls pin N to "Magnetar" so that an
// integrator who mistakenly fed Magnetar cSHAKE bytes into a
// non-Magnetar cSHAKE engine would get a deterministic mismatch.
const functionName = "Magnetar"
// cshake256 returns the first outLen bytes of cSHAKE256(input, N,
// customisation) per SP 800-185 §3.
func cshake256(input []byte, outLen int, customisation string) []byte {
h := sha3.NewCShake256([]byte(functionName), []byte(customisation))
_, _ = h.Write(input)
out := make([]byte, outLen)
_, _ = h.Read(out)
return out
}
// kmac256 returns KMAC256(key, msg, outLen, customisation) per
// SP 800-185 §4.
func kmac256(key, msg []byte, outLen int, customisation string) []byte {
preamble := bytepad(encodeString(key), 136)
body := append(append([]byte{}, preamble...), msg...)
body = append(body, rightEncode(uint64(outLen)*8)...)
h := sha3.NewCShake256([]byte("KMAC"), []byte(customisation))
_, _ = h.Write(body)
out := make([]byte, outLen)
_, _ = h.Read(out)
return out
}
// SP 800-185 §2.3 encoders.
// leftEncode returns left_encode(x) per SP 800-185 §2.3.1.
// Operates on the BIT length: callers pre-multiply by 8 when
// encoding a byte-length.
func leftEncode(x uint64) []byte {
if x == 0 {
return []byte{0x01, 0x00}
}
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], x)
i := 0
for i < 7 && buf[i] == 0 {
i++
}
out := make([]byte, 0, 9-i)
out = append(out, byte(8-i))
out = append(out, buf[i:]...)
return out
}
// rightEncode returns right_encode(x) per SP 800-185 §2.3.1.
func rightEncode(x uint64) []byte {
if x == 0 {
return []byte{0x00, 0x01}
}
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], x)
i := 0
for i < 7 && buf[i] == 0 {
i++
}
out := make([]byte, 0, 9-i)
out = append(out, buf[i:]...)
out = append(out, byte(8-i))
return out
}
// encodeString returns encode_string(s) = left_encode(bit_len(s))
// || s per SP 800-185 §2.3.2.
func encodeString(s []byte) []byte {
out := leftEncode(uint64(len(s)) * 8)
out = append(out, s...)
return out
}
// bytepad returns bytepad(x, w) = left_encode(w) || x ||
// pad-to-w-bytes per SP 800-185 §2.3.3.
func bytepad(x []byte, w int) []byte {
prefix := leftEncode(uint64(w))
out := make([]byte, 0, len(prefix)+len(x)+w)
out = append(out, prefix...)
out = append(out, x...)
for len(out)%w != 0 {
out = append(out, 0x00)
}
return out
}
// transcriptHash binds an ordered tuple of byte-strings into a
// single 48-byte digest under the named customisation tag.
//
// Encoding is SP 800-185 TupleHash256-style: for each part, prepend
// left_encode(bit_len(part)) so the boundary between parts is
// unambiguous regardless of part lengths.
func transcriptHash(customisation string, parts ...[]byte) [48]byte {
buf := make([]byte, 0, 64+len(parts)*40)
buf = append(buf, leftEncode(uint64(len(parts)))...)
for _, p := range parts {
buf = append(buf, encodeString(p)...)
}
out := cshake256(buf, 48, customisation)
var ret [48]byte
copy(ret[:], out)
return ret
}
// transcriptHash32 is the 32-byte counterpart used where a shorter
// digest is sufficient (commit digests, MAC tags).
func transcriptHash32(customisation string, parts ...[]byte) [32]byte {
buf := make([]byte, 0, 64+len(parts)*40)
buf = append(buf, leftEncode(uint64(len(parts)))...)
for _, p := range parts {
buf = append(buf, encodeString(p)...)
}
out := cshake256(buf, 32, customisation)
var ret [32]byte
copy(ret[:], out)
return ret
}
// ctEqualSlice is a constant-time byte-slice equality check.
// Returns false if lengths differ; otherwise scans every byte
// regardless of where the first mismatch occurs.
func ctEqualSlice(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := range a {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// ctEqual32 is the 32-byte fast path for ctEqualSlice.
func ctEqual32(a, b [32]byte) bool {
var diff byte
for i := 0; i < 32; i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// nodeIDLess returns true if a < b in canonical big-endian byte
// order. Used for sorting committee membership canonically.
func nodeIDLess(a, b NodeID) bool {
for i := 0; i < len(a); i++ {
if a[i] != b[i] {
return a[i] < b[i]
}
}
return false
}
+230
View File
@@ -0,0 +1,230 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// types.go — public data types used across the Magnetar reference
// implementation.
// NodeID is the canonical party identifier used in all Magnetar
// protocols. The 32-byte width matches the Lux validator-ID format
// and is wide enough to host an arbitrary external identifier
// (e.g. a Hanzo IAM subject hash). Index 0 is forbidden because the
// Shamir evaluation point at x=0 holds the master secret; any party
// with nominal index 0 is rejected.
type NodeID [32]byte
// PublicKey wraps a FIPS 205 SLH-DSA public key. The byte layout is
// exactly what circl's slhdsa.PublicKey.MarshalBinary emits —
// (seed, root) concatenation per FIPS 205 §10.1 (Algorithm 21).
//
// The headline Class-N1-analog claim of Magnetar is that a Magnetar
// threshold signature against this PublicKey verifies under
// unmodified FIPS 205 slhdsa.Verify (see Verify in verify.go).
type PublicKey struct {
Mode Mode
Bytes []byte
}
// PrivateKey wraps a FIPS 205 SLH-DSA private key. Only the trusted
// dealer in keygen.go holds the full PrivateKey; threshold
// deployments hold KeyShare values produced by DKG instead.
//
// PrivateKey carries the seed it was derived from so determinism
// across re-load is preserved; the seed is the Shamir-shared
// quantity in the threshold model. Seed is variable-length (4n
// bytes per FIPS 205 §10.1).
type PrivateKey struct {
Mode Mode
Bytes []byte
Seed []byte
Pub *PublicKey
}
// KeyShare is one party's portion of a threshold-DKG output. Each
// share is a (NodeID, scalar-byte-vector) tuple where the scalar
// vector is the Shamir share of the underlying SLH-DSA seed at the
// party's Shamir evaluation point.
//
// The Share field carries seed_size × uint16 lanes (big-endian),
// giving the Shamir share value in GF(257) at every byte position
// of the underlying seed. Wire layout is independent of the cSHAKE
// transcript mix.
//
// The evaluation point must be non-zero and distinct across the
// committee. v0.1 derives it from the 1-indexed committee position.
type KeyShare struct {
NodeID NodeID
EvalPoint uint32 // Shamir x-coordinate in [1, 257); distinct per party
Share []byte // seed_size × uint16 big-endian GF(257) share values
Pub *PublicKey
Mode Mode
}
// Signature is a FIPS 205 SLH-DSA signature in its standard byte
// layout per FIPS 205 §10.2 (Algorithm 22). No Magnetar envelope
// is applied. A relying party that can verify SLH-DSA can verify
// a Magnetar Signature with no code change.
type Signature struct {
Mode Mode
Bytes []byte
}
// GroupKey is the joint public key produced by DKG. The
// distinction between GroupKey and PublicKey is documentary only:
// they carry the same bytes. A *GroupKey is a *PublicKey under a
// renamed alias that signals "this is a threshold-DKG output."
type GroupKey = PublicKey
// DKGRound1Msg is the broadcast emitted by DKGSession.Round1.
//
// Protocol shape (v0.1 reveal-and-aggregate):
// The dealer broadcasts one envelope per recipient that carries
// the recipient's Shamir share of the dealer's contribution AND
// the full dealer-contribution-to-the-joint-seed. Combining N
// such per-dealer contributions at Round 2 lets each party
// compute the joint master public key locally.
//
// v0.1 ships the envelope IN PLAINTEXT for KAT determinism;
// production deployments wrap it under ML-KEM-768 per the same
// pattern Pulsar uses. The honest v0.1 trust caveat (reveal-and-
// aggregate aggregator-as-TCB) is unaffected by this choice; both
// modes equally reveal the seed at Combine time. See SPEC.md
// "Trust model" for full disclosure.
type DKGRound1Msg struct {
NodeID NodeID
Envelopes map[NodeID]DKGShareEnvelope
}
// DKGShareEnvelope is the v0.1 plaintext envelope carrying one
// recipient's per-byte Shamir share of the dealer's seed
// contribution AND the full dealer contribution.
//
// Wire layout (v0.1 plaintext): Share || Contribution where
// - Share is seed_size × uint16 big-endian (2 × seed_size bytes)
// - Contribution is seed_size bytes (the dealer's contribution
// to the joint seed, used at Round 2 to compute the master
// public key).
//
// v0.2 wraps Sealed under ML-KEM-768 to the recipient's long-term
// identity public key, matching the BLOCKERS.md CR-8 pattern Pulsar
// adopted. v0.2 wire layout: KEMCiphertext || Sealed.
type DKGShareEnvelope struct {
Share []byte // 2 × seed_size bytes (GF(257) Shamir share, big-endian uint16 per byte position)
Contribution []byte // seed_size bytes (dealer's contribution to the joint seed)
}
// DKGRound2Msg is the broadcast emitted by DKGSession.Round2: the
// per-party transcript-digest agreement digest. Used to bind every
// party's view of the ordered Round-1 envelope set so equivocation
// is detectable.
type DKGRound2Msg struct {
NodeID NodeID
Digest [32]byte
}
// DKGOutput is the result of a successful DKG.
//
// On success, GroupPubkey is the joint FIPS 205 SLH-DSA public
// key, SecretShare is the calling party's Shamir share of the
// group seed, TranscriptHash is the 48-byte transcript digest that
// the chain can pin in its validator-set commitment, and
// AbortEvidence is nil.
type DKGOutput struct {
GroupPubkey *PublicKey
SecretShare *KeyShare
TranscriptHash [48]byte
AbortEvidence *AbortEvidence
}
// AbortEvidence is a signed complaint emitted by an honest party
// when it detects deviation. The protocol family commits to
// identifiable abort: every detected deviation produces verifiable
// evidence suitable for slashing.
type AbortEvidence struct {
Kind ComplaintKind
Accuser NodeID
Accused NodeID
Epoch uint64
Evidence []byte
Signature []byte
}
// ComplaintKind is the taxonomy of identifiable-abort complaint
// types. Values are wire-stable (do not renumber).
type ComplaintKind uint8
const (
// ComplaintEquivocation: a dealer broadcast distinct envelopes
// or commit vectors to distinct recipients.
ComplaintEquivocation ComplaintKind = 1
// ComplaintBadDelivery: the share delivered to the accuser
// fails verification against the dealer's contribution.
ComplaintBadDelivery ComplaintKind = 2
// ComplaintMACFailure: a MAC from the accused failed
// verification. Reserved for v0.2 where per-pair MACs are
// added; v0.1 has no MAC layer.
ComplaintMACFailure ComplaintKind = 3
)
// String returns the canonical name of the complaint kind.
func (k ComplaintKind) String() string {
switch k {
case ComplaintEquivocation:
return "equivocation"
case ComplaintBadDelivery:
return "bad-delivery"
case ComplaintMACFailure:
return "mac-failure"
default:
return "unknown"
}
}
// Round1Message and Round2Message in threshold signing have a
// different shape from DKG. The threshold protocol's Round-1
// emits a commit + per-peer MAC; Round-2 reveals (mask,
// masked_share). The v0.1 reveal-and-aggregate model collapses
// the per-signature ceremony into a single Sign on the
// reconstructed seed.
// Round1Message is the broadcast emitted by ThresholdSigner.Round1.
//
// v0.1 protocol shape: each signer commits to a masked version of
// their seed share via a cSHAKE256 commit digest D_i. Round-2
// reveals (mask, masked_share) so peers can re-derive D_i and the
// aggregator can recover the share via XOR.
//
// MACs are intentionally omitted from v0.1: the reveal-and-
// aggregate model already TRUSTS the aggregator (it sees the
// reconstructed seed). Adding MACs would close a session-bind
// hole that doesn't matter under the v0.1 trust caveat; v0.2
// adds them when the trust model tightens. See SPEC.md.
type Round1Message struct {
NodeID NodeID
SessionID [16]byte
Attempt uint32
Commit [32]byte // D_i = cSHAKE256(mask || masked || tau_1)
}
// Round2Message carries the (mask, masked_share) reveal. The
// aggregator combines this with the matching Round1Message to
// recover the underlying share, then Lagrange-reconstructs the
// master seed.
//
// PartialSig wire layout: mask || masked_share (each is 2 ×
// seed_size bytes). The aggregator parses these per share.
type Round2Message struct {
NodeID NodeID
SessionID [16]byte
Attempt uint32
PartialSig []byte
}
// shareWireSize returns the byte length of one Shamir share's
// wire representation for a given seed size.
func shareWireSize(seedSize int) int {
return seedSize * 2
}
+110
View File
@@ -0,0 +1,110 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// verify.go — the canonical consensus-consumer entry point.
//
// This file is the Magnetar Class-N1-analog manifesto in code:
// Verify literally dispatches to circl/slhdsa.Verify, which is the
// FIPS 205 §10.3 verifier verbatim. No Magnetar-specific logic; no
// threshold-specific extension fields; no envelope. A signature
// produced by Combine (threshold.go) flows through the SAME Verify
// code path as a single-party Sign output.
//
// Any change to Verify breaks output interchangeability with
// single-party FIPS 205.
import (
"errors"
"github.com/cloudflare/circl/sign/slhdsa"
)
// Verification errors. Returning a typed error rather than a panic
// is mandated by the no-panic-in-verify-path discipline.
var (
// ErrInvalidSignature is returned when the signature does not
// verify under FIPS 205 SLH-DSA.Verify(pk, message, signature).
ErrInvalidSignature = errors.New("magnetar: signature verification failed")
// ErrSignatureWrongSize is returned when the signature byte
// length does not match the expected FIPS 205 signature size
// for the params.Mode.
ErrSignatureWrongSize = errors.New("magnetar: signature wrong size")
// ErrPublicKeyWrongSize is returned when the public-key byte
// length does not match the expected FIPS 205 public-key size.
ErrPublicKeyWrongSize = errors.New("magnetar: public-key wrong size")
// ErrNilSignature is returned when sig is nil.
ErrNilSignature = errors.New("magnetar: nil signature")
// ErrNilPublicKey is returned when groupPubkey is nil.
ErrNilPublicKey = errors.New("magnetar: nil public key")
)
// Verify is the canonical consensus-consumer entry point for
// Magnetar signature verification.
//
// Returns nil if the signature is valid under FIPS 205 SLH-DSA.Verify;
// a typed error otherwise. Never panics: caller code in the consensus
// path can call Verify in a hot loop without a recover() shroud.
//
// ctx is the FIPS 205 context string (≤255 bytes); pass nil for the
// empty context. Match this to whatever was passed at Sign time.
//
// Class-N1-analog: this function MUST remain a thin dispatch over
// the FIPS 205 verifier. Adding logic here breaks output
// interchangeability with single-party FIPS 205 — the whole point
// of the Magnetar threshold variant.
func Verify(params *Params, groupPubkey *PublicKey, message []byte, sig *Signature) error {
return VerifyCtx(params, groupPubkey, message, nil, sig)
}
// VerifyCtx is the context-aware variant of Verify. The FIPS 205
// context string ctx (≤255 bytes) is included in the verification
// transcript per FIPS 205 §10.3.
func VerifyCtx(params *Params, groupPubkey *PublicKey, message, ctx []byte, sig *Signature) error {
if err := params.Validate(); err != nil {
return err
}
if groupPubkey == nil {
return ErrNilPublicKey
}
if sig == nil {
return ErrNilSignature
}
if groupPubkey.Mode != params.Mode {
return ErrModeMismatch
}
if sig.Mode != params.Mode {
return ErrModeMismatch
}
if len(groupPubkey.Bytes) != params.PublicKeySize {
return ErrPublicKeyWrongSize
}
if len(sig.Bytes) != params.SignatureSize {
return ErrSignatureWrongSize
}
if len(ctx) > 255 {
return ErrCtxTooLong
}
if !slhVerify(params.ID, groupPubkey.Bytes, message, ctx, sig.Bytes) {
return ErrInvalidSignature
}
return nil
}
// slhVerify dispatches to circl/slhdsa.Verify for the given
// parameter ID. This is the only place outside keygen/sign that
// touches the FIPS 205 implementation; centralising it makes the
// Class-N1-analog dispatch auditable as a single function.
func slhVerify(id slhdsa.ID, packedPk, message, ctx, sig []byte) bool {
pk := slhdsa.PublicKey{ID: id}
if err := pk.UnmarshalBinary(packedPk); err != nil {
return false
}
return slhdsa.Verify(&pk, slhdsa.NewMessage(message), sig, ctx)
}
+50
View File
@@ -0,0 +1,50 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package magnetar
// zeroize.go — best-effort secret-buffer zeroization.
//
// Threat model: a Magnetar process holding reconstructed secret
// material (e.g. the Combine aggregator's master seed) is at risk
// of coredump / /proc/self/mem / swap-file exfiltration if the
// secret is left live on the heap or stack after use. Go provides
// no native `runtime.Memzero` and the GC may copy buffers around;
// zeroize is a defense-in-depth measure, not a guarantee.
//
// We deliberately do NOT use `defer` for zeroize calls — the
// hot-path callers are short, and explicit zeroization at the
// return site keeps the secret-handling code path locally legible.
//
// The v0.1 reveal-and-aggregate trust caveat: even with perfect
// zeroization the aggregator process has a non-zero secret
// lifetime window. See SPEC.md "Trust model" and
// DEPLOYMENT-RUNBOOK.md for operational mitigations (mlock,
// ptrace-off, etc).
// zeroizeBytes overwrites every byte of b with 0.
func zeroizeBytes(b []byte) {
for i := range b {
b[i] = 0
}
}
// zeroizeU16Slice overwrites every uint16 element with 0.
func zeroizeU16Slice(s []uint16) {
for i := range s {
s[i] = 0
}
}
// zeroizePrivateKey wipes the secret-bearing fields of a
// PrivateKey (Bytes and Seed). Sets references to nil where
// possible so the underlying backing arrays can be GC'd; before
// they are, the bytes have been overwritten in place so a
// concurrent coredump captures zeros.
func zeroizePrivateKey(sk *PrivateKey) {
if sk == nil {
return
}
zeroizeBytes(sk.Bytes)
zeroizeBytes(sk.Seed)
}