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

113 lines
3.9 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package thbs
import "sync"
// slot.go — per-party anti-equivocation state machine.
//
// Hash-based one-time signatures (WOTS+) are unsafe to reuse: signing
// two distinct messages under the same WOTS+ leaf leaks the secret
// chain heads at positions where the message-derived digit values
// differ. THBS therefore REQUIRES that each (party, slot) tuple be
// used at most once.
//
// We do not RELY on the slot allocator being honest. The state
// machine here is a verifier of the local sign request: if the same
// slot is requested twice with the same message digest, the second
// call is idempotent (same shares emitted, same Tag, same byte
// output); if the same slot is requested with a DIFFERENT message
// digest, the second call returns ErrEquivocation with verifiable
// evidence the consensus layer can slash on.
// slotRecord is the per-slot record a party keeps.
type slotRecord struct {
Digest [32]byte
// The previously-emitted PartialSignature so we can attach it as
// evidence on a subsequent equivocation attempt.
Partial PartialSignature
}
// stateImpl is a concurrency-safe slot state machine. The exported
// type StateStore (map alias) is the wire-shape; this is the runtime
// guard.
type stateImpl struct {
mu sync.Mutex
records map[Slot]slotRecord
}
// newStateImpl returns a fresh slot guard.
func newStateImpl() *stateImpl {
return &stateImpl{records: make(map[Slot]slotRecord)}
}
// checkAndRecord enforces the slot invariant.
//
// Behaviour:
// - First call for `slot`: record (digest, partial) and return (false, nil, nil).
// - Second call with matching digest: return (true, &prev, nil).
// The caller MUST return the previous partial verbatim
// (idempotent re-emit).
// - Second call with mismatched digest: return (false, &prev,
// ErrEquivocation). Caller wraps in EquivocationError with full
// evidence.
func (s *stateImpl) checkAndRecord(slot Slot, digest [32]byte, partial PartialSignature) (bool, *slotRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
if prev, ok := s.records[slot]; ok {
if prev.Digest == digest {
return true, &prev, nil
}
return false, &prev, ErrEquivocation
}
s.records[slot] = slotRecord{Digest: digest, Partial: partial}
return false, nil, nil
}
// snapshot returns a copy of the per-slot digest map. The
// PartialSignature payload is intentionally omitted from the snapshot:
// the StateStore wire shape is (slot -> digest), not full signature
// material. The full record is held only in the runtime guard.
func (s *stateImpl) snapshot() StateStore {
s.mu.Lock()
defer s.mu.Unlock()
out := make(StateStore, len(s.records))
for k, v := range s.records {
out[k] = v.Digest
}
return out
}
// PrivateShareGuard couples a PrivateShare with the runtime slot
// guard. SignShare runs against a guarded share.
//
// The dealer returns the bare PrivateShare; callers wrap with
// NewGuard before signing. (Why wrap separately: serialisation of a
// PrivateShare should not include the runtime guard's mutex state.)
//
// params and evalPoint are runtime-only sign-time hints set by
// NewGuardWithParams.
type PrivateShareGuard struct {
Share *PrivateShare
state *stateImpl
params HBSParams
evalPoint uint16
}
// NewGuard wraps a PrivateShare with a fresh slot state machine. If
// the PrivateShare already carries AntiEquivState (e.g. restored from
// disk), the prior records are imported.
func NewGuard(share *PrivateShare) *PrivateShareGuard {
s := newStateImpl()
if share.AntiEquivState != nil {
for slot, digest := range share.AntiEquivState {
s.records[slot] = slotRecord{Digest: digest}
}
}
return &PrivateShareGuard{Share: share, state: s}
}
// Snapshot returns the StateStore wire shape suitable for serialisation.
func (g *PrivateShareGuard) Snapshot() StateStore { return g.state.snapshot() }