Files
magnetar/ref/go/pkg/thbs/shamir.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

197 lines
5.6 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
import "errors"
// shamir.go — per-byte Shamir over GF(257).
//
// The thbs package shares INDIVIDUAL ELEMENTS (each of length N bytes)
// rather than a single global seed. Each element is shared via the
// same byte-wise GF(257) scheme used elsewhere in Magnetar; the
// invariant "no full-seed reconstruction" is enforced by the API
// shape, not by switching field.
const shamirPrime uint32 = 257
// maxParties is the maximum committee size that GF(257) admits with
// distinct non-zero evaluation points.
const maxParties = 256
var (
errInvalidThreshold = errors.New("thbs/shamir: invalid threshold")
errTooManyShares = errors.New("thbs/shamir: too many parties for GF(257)")
errNoShares = errors.New("thbs/shamir: no shares")
errDuplicateX = errors.New("thbs/shamir: duplicate evaluation point")
errZeroX = errors.New("thbs/shamir: zero evaluation point")
)
// shareElement splits an N-byte secret into n shares with reconstruction
// threshold t. The coeffStream provides the (t-1) per-byte non-constant
// coefficients; if empty, a deterministic stretch via cSHAKE is used.
//
// Returns shares[i].Y of length N for each party i, where shares[i].X
// is the i-th party's evaluation point.
func shareElement(secret []byte, points []uint16, t int, coeffStream []byte) ([]shamirSlice, error) {
if t < 1 || len(points) < t {
return nil, errInvalidThreshold
}
if len(points) > maxParties {
return nil, errTooManyShares
}
n := len(secret)
needed := (t - 1) * n * 2
if needed < 2 {
needed = 2
}
if len(coeffStream) < needed {
coeffStream = hashN(needed, tagShamir, coeffStream)
}
// coeffs[d][b] = degree-d coefficient at byte position b.
coeffs := make([][]uint16, t)
for d := 0; d < t; d++ {
coeffs[d] = make([]uint16, n)
}
for b := 0; b < n; b++ {
coeffs[0][b] = uint16(secret[b]) % uint16(shamirPrime)
}
off := 0
for d := 1; d < t; d++ {
for b := 0; b < n; b++ {
r := uint32(coeffStream[off])<<8 | uint32(coeffStream[off+1])
off += 2
coeffs[d][b] = uint16(r % shamirPrime)
}
}
out := make([]shamirSlice, len(points))
for i, x := range points {
if x == 0 {
return nil, errZeroX
}
out[i].X = x
out[i].Y = make([]uint16, n)
xu := uint32(x)
for b := 0; b < n; b++ {
acc := uint32(coeffs[t-1][b])
for d := t - 2; d >= 0; d-- {
acc = (acc*xu + uint32(coeffs[d][b])) % shamirPrime
}
out[i].Y[b] = uint16(acc)
}
}
return out, nil
}
// reconstructElement Lagrange-interpolates an N-byte secret at x=0.
// The caller MUST pass exactly the shares used to reconstruct one
// element. The output[b] is in [0, 257); for actual byte recovery the
// caller routes through cSHAKE (see sign.go reconstructBytes).
func reconstructElement(shares []shamirSlice, n int) ([]uint16, error) {
if len(shares) == 0 {
return nil, errNoShares
}
seen := make(map[uint16]struct{}, len(shares))
for _, s := range shares {
if s.X == 0 {
return nil, errZeroX
}
if _, dup := seen[s.X]; dup {
return nil, errDuplicateX
}
seen[s.X] = struct{}{}
if len(s.Y) != n {
return nil, errInvalidThreshold
}
}
t := len(shares)
lambdas := make([]uint16, t)
for i := 0; i < t; i++ {
num := uint32(1)
den := uint32(1)
xi := uint32(shares[i].X)
for j := 0; j < t; j++ {
if i == j {
continue
}
xj := uint32(shares[j].X)
negXj := (shamirPrime - xj%shamirPrime) % shamirPrime
num = (num * negXj) % shamirPrime
diff := (shamirPrime + xi - xj) % shamirPrime
den = (den * diff) % shamirPrime
}
denInv := modInv(den, shamirPrime)
lambdas[i] = uint16((num * denInv) % shamirPrime)
}
out := make([]uint16, n)
for b := 0; b < n; 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
}
// modInv computes a^-1 mod p via Fermat (p prime).
func modInv(a, p uint32) uint32 { return modPow(a, p-2, p) }
func modPow(base, exp, p uint32) uint32 {
r := uint32(1)
b := base % p
for exp > 0 {
if exp&1 == 1 {
r = (r * b) % p
}
b = (b * b) % p
exp >>= 1
}
return r
}
// shamirSlice carries one byte-wise share of length N at evaluation
// point X. Internal type; the exported API uses ElementShare which
// adds (ID, Steps).
type shamirSlice struct {
X uint16
Y []uint16
}
// gfBytesToBytes maps a length-N GF(257) reconstruction back to N raw
// bytes.
//
// In THBS we INVARIANT: the original secret bytes are always drawn
// from [0, 256), so when split as Shamir polynomial constant terms
// over GF(257), the Lagrange interpolation at x=0 returns EXACTLY the
// original byte value (a value in [0, 256), never 256). We assert
// this and convert directly. The slotMix parameter is retained as a
// belt-and-suspenders binding to detect tamper at the gf-byte
// interface, but the byte value comes from the GF reconstruction
// directly (no hash mix — that would prevent byte-equality with the
// secret).
//
// If reconstruction returns 256 for any byte, the shares have been
// tampered: the caller (Aggregate) will detect this when the WOTS+
// chain endpoint or FORS root check fails. To make the error path
// explicit, we map 256 -> 0 with the understanding that the
// subsequent endpoint check WILL reject.
func gfBytesToBytes(gf []uint16, n int, slotMix []byte) []byte {
_ = slotMix
out := make([]byte, n)
for b := 0; b < n; b++ {
if gf[b] >= 256 {
// Cannot happen for honest shares; signal via 0 so the
// endpoint check fails downstream.
out[b] = 0
continue
}
out[b] = byte(gf[b])
}
return out
}