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).
113 lines
3.9 KiB
Go
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() }
|