mirror of
https://github.com/luxfi/magnetar.git
synced 2026-07-27 02:53:47 +00:00
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:
@@ -0,0 +1,9 @@
|
||||
# Build / test scratch directories
|
||||
vectors-first/
|
||||
vectors-second/
|
||||
vectors-tmp/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
*~
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user