Files
magnetar/ref/go/pkg/magnetar/combine.go
T
Hanzo AI 3951c5a46c v0.4.2: TRUE threshold HBS (pkg/thbs) + public-BFT-safe aggregate-sigs
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).
2026-05-21 17:34:41 -07:00

272 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 CombineWithSeedReconstruction 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.
//
// !! IMPORTANT — PUBLIC-BFT SAFETY !!
//
// This file's API REQUIRES that the aggregator process is part of
// the Trusted Computing Base (TCB): for the duration of one
// CombineWithSeedReconstruction call, the reconstructed master
// SLH-DSA seed is live in the aggregator's address space. This is a
// hard requirement, not a "best-practice" recommendation.
//
// SLH-DSA (FIPS 205) is a hash-based signature scheme over WOTS+ /
// FORS / Merkle trees. It has NO algebraic structure that admits a
// FROST-style threshold aggregation of partial signatures into a
// final signature. The literature has confirmed this is fundamental
// to hash-based signing:
//
// - Cozzo & Smart (EUROCRYPT 2019), "Sharing the LUOV" —
// establishes that hash-based signature schemes are the worst
// case for threshold MPC because every internal SHAKE/SHA-2
// evaluation is a non-linear function of the secret seed.
// - Bonte, Smart, Tan (2023), "Threshold SPHINCS+" — exhaustive
// analysis confirming there is no efficient threshold SLH-DSA
// scheme that produces a single FIPS 205-shaped signature
// without reconstructing the seed.
// - NIST IR 8214 / MPTC submission notes classify SLH-DSA as
// "highest threshold-MPC cost" among the FIPS 20{3,4,5}
// family.
// - FIPS 205 §6 (slh_sign_internal) shows that the per-signature
// hypertree address material PRF_msg(SK.prf, opt_rand, M)
// is not Lagrange-aggregatable.
//
// Therefore Magnetar's v0.1 construction is "reveal-and-aggregate":
// each signer commits to a mask + masked seed-share, then reveals
// (mask, masked_share) so the aggregator can Lagrange-reconstruct
// the master seed and produce a single FIPS 205-byte-identical
// signature. The aggregator IS the TCB; this is the honest cost of
// shipping a FIPS 205-bytewise-identical threshold output today.
//
// USE THIS FUNCTION ONLY WHEN:
// - The aggregator runs in a TEE (Intel TDX / SEV-SNP / SGX), OR
// - The aggregator is the same process as the custody host (M-Chain
// custody pattern; cf. LP-134 thresholdvm M-Chain mode), OR
// - You are reproducing the FIPS 205 SignDeterministic byte-equal
// baseline for KAT validation, audit cross-check, or formal
// proof refinement.
//
// FOR PUBLIC BFT VALIDATOR QUORUMS: use the per-validator
// AggregateSignatures path in aggregate.go instead. There each
// validator i signs the message under its OWN SLH-DSA keypair
// (sk_i, pk_i), the consensus layer collects N separate signatures,
// and verification iterates per-signer. Wire size is N × |σ|; for
// SHAKE-192s this is 16 KiB per validator. A Z-Chain Groth16 rollup
// can compress the bundle to ~192 bytes (separate concern; that
// path is the standard SOTA answer for "BFT-ready post-quantum
// finality" without any single party holding the master seed).
import (
"crypto/rand"
)
// CombineWithSeedReconstruction produces a FIPS 205 SLH-DSA signature
// from a quorum of Round-2 reveals BY RECONSTRUCTING THE MASTER
// SEED IN THE AGGREGATOR PROCESS.
//
// REQUIRES TEE for public deployment. NOT public-BFT-safe.
// For public BFT, use AggregateSignatures in aggregate.go.
//
// The reconstruction trust caveat is hard: SLH-DSA is hash-based and
// admits no Lagrange-aggregatable response. The output is byte-
// identical to single-party FIPS 205 SignDeterministic on the
// reconstructed master seed; achieving that bytewise output requires
// holding the seed in memory once. See the file header for the
// literature on this point (Cozzo-Smart 2019, Bonte et al. 2023,
// FIPS 205 §6).
//
// 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).
//
// CombineWithSeedReconstruction is a pure function — it does not
// require ThresholdSigner state. Any honest party in the quorum
// (or an external aggregator) can call it 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 CombineWithSeedReconstruction(
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
}