mirror of
https://github.com/luxfi/magnetar.git
synced 2026-07-27 02:53:47 +00:00
Introduce TWO new signing modes plus a clarifying rename of the legacy
v0.1 path.
1. pkg/thbs/ — TRUE threshold hash-based signatures in the McGrew et al.
sense (IACR ePrint 2019/793 / IRTF draft-mcgrew-hash-sigs line). For
HBS schemes the signature reveals SELECTED secret elements (WOTS+
chain heads selected by the message-digest base-w digits + FORS
secret leaves selected by the FORS index digest). Threshold signing
here Shamir-shares each secret element across the committee; for
each message parties release shares ONLY for the SELECTED elements;
the combiner Lagrange-reconstructs just those elements; the
verifier sees an ordinary HBS-style signature.
Subpackage layout:
- thbs.go — types per the requested API shape (DKGConfig,
PublicKey, PrivateShare, PartialSignature,
FinalSignature, Evidence, EquivocationError).
- dealer.go — v1 dealer-backed DKG. The dealer Shamir-shares each
secret element across the committee via per-byte
GF(257); the dealer seed is zeroised before return.
v2 will replace with public DKG.
- wots.go — WOTS+ (Winternitz w=16, FIPS 205-style base-w digit
+ checksum decomposition; cSHAKE-256 hash chains).
- fors.go — FORS (k subtrees, height a, per-leaf binary Merkle).
- tree.go — public Merkle tree over WOTS+ leaf-roots.
- slot.go — anti-equivocation slot guard. Same-slot-different-
digest emits Evidence{party, slot, digest_a/b,
share_a/b} for the slashing layer.
- sign.go — SignShare + Aggregate + Verify.
- shamir.go — byte-wise Shamir over GF(257); elements are shared
directly, not seeds.
- hash.go — cSHAKE-256 with the "Magnetar-THBS" function-name
and per-tag domain separation.
24 unit tests pin every invariant: no-seed-exposure,
selected-elements-only for both WOTS+ and FORS, t-of-n threshold,
anti-equivocation, cross-slot/cross-message rejection, tamper
detection.
Honest v1 scope (documented in THBS-SPEC.md):
- Setup is DEALER-BACKED. v2 replaces with public DKG.
- Helper data shipped alongside the public key (McGrew et al.
permit this).
- Verifier is a CUSTOM HBS verifier; v3 will produce FIPS 205-byte-
identical output.
Hard invariant enforced by the package shape:
OK: reconstructElement(slot, elementID, shares)
Forbidden: ReconstructSeed, ReconstructPrivateKey,
ExpandPrivateKey, DeriveAllFutureElements
The only Reconstruct symbol in thbs/*.go is the unexported
reconstructElement in shamir.go.
2. pkg/magnetar/aggregate.go — public-BFT-safe N-of-N collected
signatures. Each validator holds its OWN SLH-DSA keypair (no DKG,
no shared seed). Primitives: GenerateValidatorKey, SignBundle,
VerifyBundle, AggregateSignatures, VerifyAggregated. 10 tests.
3. pkg/magnetar/combine.go — Combine renamed to
CombineWithSeedReconstruction throughout the package + callers
(e2e_test, threshold_test, n1_byte_equality_test, fuzz_test,
genkat, ct/dudect bridge) to make the TEE-only trust caveat
explicit at the API surface. KAT byte-equality preserved: the
function body is unchanged.
Refs: McGrew, Fluhrer, Gazdag, Kampanakis, Morton, Westerbaan,
"Coalition and Threshold Hash-Based Signatures" (IACR ePrint 2019/793);
Bonte, Smart, Tan, "Threshold SPHINCS+", PKC 2024 (the negative result
informing our v1-ships-a-custom-HBS-verifier scope choice).
382 lines
13 KiB
Go
382 lines
13 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package thbs
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"errors"
|
|
"io"
|
|
"sort"
|
|
)
|
|
|
|
// dealer.go — v1 dealer-backed DKG.
|
|
//
|
|
// HONEST TRUST CAVEAT: a single party (the dealer) generates a master
|
|
// seed, expands it into per-element secrets, shares each secret via
|
|
// per-byte Shamir over GF(257), and distributes shares. The dealer
|
|
// learns every secret element at setup time; the dealer MUST erase
|
|
// the seed before going live. This is the McGrew et al. v1 setup
|
|
// pattern.
|
|
//
|
|
// v2 replaces this with a public DKG (PVSS-based) so no single party
|
|
// learns the secret elements. The wire shape of PrivateShare /
|
|
// PublicKey is forward-compatible with the v2 DKG output.
|
|
//
|
|
// What the dealer DOES emit publicly:
|
|
// - PublicKey.Root (the top-tree root)
|
|
// - HelperData (LeafRoots, AuthPaths, ChainEndpoints, FORS roots
|
|
// and FORS auth paths)
|
|
// - DKGTranscript (a hash binding committee + params + epoch +
|
|
// chainID + the helper data)
|
|
//
|
|
// What the dealer NEVER emits publicly:
|
|
// - The dealer seed (zeroized before return)
|
|
// - Any secret WOTS+ chain head x_{slot, j}
|
|
// - Any secret FORS leaf sk_{slot, tree, leaf}
|
|
//
|
|
// What each party RECEIVES (their PrivateShare.ElementShares):
|
|
// - A Shamir share at their EvalPoint of every secret element:
|
|
// (slot, ElementWOTS, chain_idx) -> N bytes share
|
|
// (slot, ElementFORS, tree_idx, leaf_idx) -> N bytes share
|
|
// - NO seed, NO future-signing key derivation path.
|
|
|
|
// DKGOptions extends DKGConfig with optional plumbing.
|
|
type DKGOptions struct {
|
|
// Rng is the randomness source used by the dealer to generate the
|
|
// secret material. If nil, crypto/rand.Reader is used.
|
|
Rng io.Reader
|
|
|
|
// DealerSeed, if non-nil, replaces the random dealer seed with a
|
|
// deterministic value. ONLY for test vectors. Production deployments
|
|
// MUST leave this nil.
|
|
DealerSeed []byte
|
|
}
|
|
|
|
// DKG runs the v1 dealer-backed DKG.
|
|
//
|
|
// Returns the threshold public key, the calling party's PrivateShare,
|
|
// and any error.
|
|
//
|
|
// IMPORTANT — call site responsibility: in v1 the dealer machine
|
|
// generates EVERY party's PrivateShare. The function is wired to
|
|
// produce ALL shares; in a real deployment the dealer would split
|
|
// the returned share-set across parties and zeroise the rest before
|
|
// the dealer-host shuts down. See DKGAll for that path.
|
|
//
|
|
// This helper signature returns the PrivateShare for the FIRST
|
|
// participant only, to match the user-spec signature. To obtain every
|
|
// party's share, call DKGAll.
|
|
func DKG(config DKGConfig) (PublicKey, PrivateShare, error) {
|
|
pk, shares, err := DKGAll(config, DKGOptions{})
|
|
if err != nil {
|
|
return PublicKey{}, PrivateShare{}, err
|
|
}
|
|
if len(shares) == 0 {
|
|
return PublicKey{}, PrivateShare{}, errors.New("thbs: empty committee")
|
|
}
|
|
return pk, shares[0], nil
|
|
}
|
|
|
|
// DKGAll is the same as DKG but returns one PrivateShare per
|
|
// participant (indexed by position in config.Participants). Use this in
|
|
// tests and in a real dealer host where the dealer process needs all
|
|
// shares to distribute them.
|
|
func DKGAll(config DKGConfig, opts DKGOptions) (PublicKey, []PrivateShare, error) {
|
|
if err := validateDKGConfig(config); err != nil {
|
|
return PublicKey{}, nil, err
|
|
}
|
|
rng := opts.Rng
|
|
if rng == nil {
|
|
rng = rand.Reader
|
|
}
|
|
|
|
params := config.Params
|
|
n := params.N
|
|
nSlots := 1 << params.Height
|
|
|
|
// Deterministic transcript-binding tag for the dealer's per-element
|
|
// PRF expansion. Includes everything in the DKGConfig so the
|
|
// resulting shares are pinned to this exact ceremony.
|
|
tagPrefix := transcriptPrefix(config)
|
|
|
|
// Dealer seed. Either supplied (test-vector mode) or freshly
|
|
// drawn from rng.
|
|
dealerSeed := make([]byte, 64)
|
|
if opts.DealerSeed != nil {
|
|
dealerSeed = append(dealerSeed[:0], opts.DealerSeed...)
|
|
if len(dealerSeed) < 32 {
|
|
return PublicKey{}, nil, errors.New("thbs: DealerSeed too short (need >= 32 bytes)")
|
|
}
|
|
} else {
|
|
if _, err := io.ReadFull(rng, dealerSeed); err != nil {
|
|
return PublicKey{}, nil, err
|
|
}
|
|
}
|
|
defer zeroize(dealerSeed)
|
|
|
|
// Collect per-participant evaluation points.
|
|
points := make([]uint16, len(config.Participants))
|
|
for i, p := range config.Participants {
|
|
points[i] = p.EvalPoint
|
|
}
|
|
|
|
// Per-party share stores.
|
|
stores := make([]ShareStore, len(config.Participants))
|
|
for i := range stores {
|
|
stores[i] = make(ShareStore)
|
|
}
|
|
|
|
// Helper data structures we'll populate.
|
|
helper := &HelperData{
|
|
LeafRoots: make([][]byte, nSlots),
|
|
AuthPaths: make([][][]byte, nSlots),
|
|
ChainEndpoints: make([][][]byte, nSlots),
|
|
FORSPubRoots: make([][][]byte, nSlots),
|
|
FORSAuthPaths: make([][][][]byte, nSlots),
|
|
}
|
|
|
|
// For each slot we:
|
|
// 1. derive WOTS+ secret chain heads x_{slot, j}
|
|
// 2. share each across the committee (per-byte Shamir GF(257))
|
|
// 3. compute the public endpoint W_{slot, j} = H^{w-1}(x_{slot, j})
|
|
// [for the dealer's bookkeeping; same can be done from the
|
|
// reconstructed value when w-1 - 0 == w-1 steps are applied
|
|
// at sign time, but we materialise it here for HelperData]
|
|
// 4. derive FORS secret leaves sk_{slot, tree, leaf}
|
|
// 5. share each
|
|
// 6. build per-tree FORS authentication paths
|
|
// 7. compute the WOTS+ leaf-root and stash it for the top tree.
|
|
//
|
|
// After every slot, the dealer zeroises the secret material.
|
|
for slot := uint32(0); slot < uint32(nSlots); slot++ {
|
|
// 1. WOTS+ secret heads.
|
|
secrets := make([][]byte, params.WOTSChains)
|
|
endpoints := make([][]byte, params.WOTSChains)
|
|
for j := 0; j < params.WOTSChains; j++ {
|
|
secrets[j] = dealerPRF(dealerSeed, tagPrefix,
|
|
byte(ElementWOTS), slot, uint16(j), 0, 0, n)
|
|
// 2. share.
|
|
slices, err := shareElement(secrets[j], points, int(config.Threshold),
|
|
dealerPRFRaw(dealerSeed, tagPrefix, byte(ElementWOTS), slot, uint16(j), 0, 0, (int(config.Threshold)-1)*n*2+2))
|
|
if err != nil {
|
|
zeroizeMatrix(secrets)
|
|
return PublicKey{}, nil, err
|
|
}
|
|
for i := range stores {
|
|
id := ElementID{Type: ElementWOTS, Slot: slot, ChainIdx: uint16(j)}
|
|
stores[i][id] = append([]uint16{}, slices[i].Y...)
|
|
}
|
|
// 3. public endpoint.
|
|
endpoints[j] = wotsChain(params, secrets[j], slot, uint16(j), 0, params.W-1)
|
|
}
|
|
helper.ChainEndpoints[slot] = endpoints
|
|
|
|
// 7a. WOTS+ leaf root.
|
|
wotsLeaf := wotsLeafFromChainEndpoints(params, slot, endpoints)
|
|
|
|
// 4-6. FORS.
|
|
forsRoots := make([][]byte, params.FORSK)
|
|
nLeaves := 1 << params.FORSA
|
|
for tree := 0; tree < params.FORSK; tree++ {
|
|
leafImages := make([][]byte, nLeaves)
|
|
for leaf := 0; leaf < nLeaves; leaf++ {
|
|
sk := dealerPRF(dealerSeed, tagPrefix,
|
|
byte(ElementFORS), slot, uint16(tree), uint32(leaf), 0, n)
|
|
// share each leaf
|
|
slices, err := shareElement(sk, points, int(config.Threshold),
|
|
dealerPRFRaw(dealerSeed, tagPrefix, byte(ElementFORS), slot, uint16(tree), uint32(leaf), 0, (int(config.Threshold)-1)*n*2+2))
|
|
if err != nil {
|
|
zeroize(sk)
|
|
return PublicKey{}, nil, err
|
|
}
|
|
for i := range stores {
|
|
id := ElementID{Type: ElementFORS, Slot: slot,
|
|
TreeIdx: uint16(tree), LeafIdx: uint32(leaf)}
|
|
stores[i][id] = append([]uint16{}, slices[i].Y...)
|
|
}
|
|
// public image
|
|
leafImages[leaf] = forsLeafImage(params, sk, slot, uint16(tree), uint32(leaf))
|
|
zeroize(sk)
|
|
}
|
|
// Build FORS tree.
|
|
root, levels := forsBuildTree(params, leafImages, slot, uint16(tree))
|
|
forsRoots[tree] = root
|
|
// Materialise EVERY leaf's auth path (combiner only uses
|
|
// one per signature, but the helper data is public).
|
|
// We flatten by tree: helper.FORSAuthPaths[slot] becomes
|
|
// a list of `FORSK * 2^FORSA` paths, indexed as
|
|
// [tree * 2^FORSA + leaf].
|
|
for leaf := 0; leaf < nLeaves; leaf++ {
|
|
helper.FORSAuthPaths[slot] = append(
|
|
helper.FORSAuthPaths[slot],
|
|
forsAuthPath(levels, uint32(leaf), params.FORSA),
|
|
)
|
|
}
|
|
}
|
|
helper.FORSPubRoots[slot] = forsRoots
|
|
|
|
// 7b. The WOTS+ leaf root and the FORS commitment combine into
|
|
// the top-tree leaf for this slot. We bind them together so a
|
|
// single Merkle path covers both.
|
|
forsRoot := forsRootOfRoots(params, forsRoots, slot)
|
|
topLeaf := hashN(params.N, tagWotsLeaf, wotsLeaf, forsRoot,
|
|
[]byte{byte(slot >> 24), byte(slot >> 16), byte(slot >> 8), byte(slot)})
|
|
helper.LeafRoots[slot] = topLeaf
|
|
|
|
zeroizeMatrix(secrets)
|
|
}
|
|
|
|
// Build the top tree.
|
|
root, levels := treeBuild(params, helper.LeafRoots)
|
|
for slot := uint32(0); slot < uint32(nSlots); slot++ {
|
|
helper.AuthPaths[slot] = treeAuthPath(levels, slot, params.Height)
|
|
}
|
|
|
|
// DKG transcript binding.
|
|
transcript := computeDKGTranscript(config, helper, root)
|
|
|
|
// Qualified set = all parties for v1 (no complaint rounds).
|
|
qs := NewBitmap(len(config.Participants))
|
|
for i := range config.Participants {
|
|
qs.Set(i)
|
|
}
|
|
|
|
pk := PublicKey{
|
|
Params: params,
|
|
Root: root,
|
|
DKGTranscript: transcript,
|
|
QualifiedSet: qs,
|
|
HelperData: helper,
|
|
}
|
|
|
|
out := make([]PrivateShare, len(config.Participants))
|
|
for i, p := range config.Participants {
|
|
out[i] = PrivateShare{
|
|
PartyID: p.ID,
|
|
Epoch: config.Epoch,
|
|
ElementShares: stores[i],
|
|
AntiEquivState: make(StateStore),
|
|
}
|
|
}
|
|
|
|
// Defensive: zero dealerSeed (the only "global secret" alive in this
|
|
// function). Per-element secrets were zeroised inline.
|
|
zeroize(dealerSeed)
|
|
|
|
return pk, out, nil
|
|
}
|
|
|
|
// validateDKGConfig sanity-checks the DKG inputs.
|
|
func validateDKGConfig(c DKGConfig) error {
|
|
if c.Threshold < 1 || int(c.Threshold) > len(c.Participants) {
|
|
return ErrInvalidThreshold
|
|
}
|
|
if len(c.Participants) > maxParties {
|
|
return ErrTooManyParties
|
|
}
|
|
if c.Params.N <= 0 || c.Params.W <= 1 || c.Params.LogW < 1 ||
|
|
c.Params.WOTSChains < 1 || c.Params.FORSK < 1 || c.Params.FORSA < 1 ||
|
|
c.Params.Height < 1 {
|
|
return ErrInvalidParams
|
|
}
|
|
// Distinct evaluation points, all non-zero.
|
|
seen := make(map[uint16]struct{}, len(c.Participants))
|
|
for _, p := range c.Participants {
|
|
if p.EvalPoint == 0 {
|
|
return ErrZeroEvalPoint
|
|
}
|
|
if _, ok := seen[p.EvalPoint]; ok {
|
|
return ErrDuplicateParty
|
|
}
|
|
seen[p.EvalPoint] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dealerPRF expands the dealer seed into a per-element secret of length
|
|
// `n` bytes. The PRF input is fully bound to (elementType, slot,
|
|
// chain/tree, leaf, byteOffset) so distinct elements get independent
|
|
// outputs and shares cannot be cross-domain-confused.
|
|
func dealerPRF(seed, tagPrefix []byte, elemType byte, slot uint32, idx16 uint16, idx32 uint32, off uint8, n int) []byte {
|
|
bind := dealerPRFBind(elemType, slot, idx16, idx32, off)
|
|
return hashN(n, tagDealerPRF, seed, tagPrefix, bind)
|
|
}
|
|
|
|
// dealerPRFRaw is dealerPRF with a caller-chosen output length, used to
|
|
// supply the coefficient stream for Shamir splitting.
|
|
func dealerPRFRaw(seed, tagPrefix []byte, elemType byte, slot uint32, idx16 uint16, idx32 uint32, off uint8, n int) []byte {
|
|
bind := dealerPRFBind(elemType, slot, idx16, idx32, off|0x80) // distinct off bit so secret PRF != coeff PRF
|
|
return hashN(n, tagDealerPRF, seed, tagPrefix, bind)
|
|
}
|
|
|
|
func dealerPRFBind(elemType byte, slot uint32, idx16 uint16, idx32 uint32, off uint8) []byte {
|
|
var buf [13]byte
|
|
buf[0] = elemType
|
|
binary.BigEndian.PutUint32(buf[1:5], slot)
|
|
binary.BigEndian.PutUint16(buf[5:7], idx16)
|
|
binary.BigEndian.PutUint32(buf[7:11], idx32)
|
|
buf[11] = off
|
|
buf[12] = 0xA1 // domain literal
|
|
return buf[:]
|
|
}
|
|
|
|
// transcriptPrefix binds DKG identity to a stable byte string for use
|
|
// in the dealer PRF.
|
|
func transcriptPrefix(c DKGConfig) []byte {
|
|
parts := make([][]byte, 0, 8+len(c.Participants))
|
|
parts = append(parts, c.ChainID)
|
|
var epochBuf [8]byte
|
|
binary.BigEndian.PutUint64(epochBuf[:], c.Epoch)
|
|
parts = append(parts, epochBuf[:])
|
|
var thrBuf [2]byte
|
|
binary.BigEndian.PutUint16(thrBuf[:], c.Threshold)
|
|
parts = append(parts, thrBuf[:])
|
|
// Participants, sorted by EvalPoint for canonicality.
|
|
sorted := append([]Participant{}, c.Participants...)
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].EvalPoint < sorted[j].EvalPoint })
|
|
for _, p := range sorted {
|
|
parts = append(parts, p.ID[:])
|
|
var xb [2]byte
|
|
binary.BigEndian.PutUint16(xb[:], p.EvalPoint)
|
|
parts = append(parts, xb[:])
|
|
}
|
|
// Params.
|
|
parts = append(parts, paramsBytes(c.Params))
|
|
// Returns 64-byte transcript prefix.
|
|
return hashN(64, tagTranscript, parts...)
|
|
}
|
|
|
|
// computeDKGTranscript binds the helper data into the transcript so the
|
|
// public key fully commits to everything the dealer emitted.
|
|
func computeDKGTranscript(c DKGConfig, h *HelperData, root []byte) []byte {
|
|
prefix := transcriptPrefix(c)
|
|
parts := make([][]byte, 0, 2+len(h.LeafRoots))
|
|
parts = append(parts, prefix, root)
|
|
for _, lr := range h.LeafRoots {
|
|
parts = append(parts, lr)
|
|
}
|
|
return hashN(48, tagTranscript, parts...)
|
|
}
|
|
|
|
func paramsBytes(p HBSParams) []byte {
|
|
buf := make([]byte, 32)
|
|
binary.BigEndian.PutUint32(buf[0:4], uint32(p.N))
|
|
binary.BigEndian.PutUint32(buf[4:8], uint32(p.W))
|
|
binary.BigEndian.PutUint32(buf[8:12], uint32(p.LogW))
|
|
binary.BigEndian.PutUint32(buf[12:16], uint32(p.WOTSChains))
|
|
binary.BigEndian.PutUint32(buf[16:20], uint32(p.FORSK))
|
|
binary.BigEndian.PutUint32(buf[20:24], uint32(p.FORSA))
|
|
binary.BigEndian.PutUint32(buf[24:28], uint32(p.Height))
|
|
return buf
|
|
}
|
|
|
|
func zeroizeMatrix(m [][]byte) {
|
|
for i := range m {
|
|
zeroize(m[i])
|
|
m[i] = nil
|
|
}
|
|
}
|
|
|