mirror of
https://github.com/luxfi/magnetar.git
synced 2026-07-27 02:53:47 +00:00
Merge branch 'research/magnetar-threshold-slhdsa'
This commit is contained in:
@@ -0,0 +1,530 @@
|
|||||||
|
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
package magnetar
|
||||||
|
|
||||||
|
// fors_threshold_open.go --- the ONE concretely-buildable, genuinely
|
||||||
|
// no-reconstruct, stock-FIPS-205-verifiable Track-B sub-protocol:
|
||||||
|
// DISTRIBUTED FORS-LEAF THRESHOLD OPENING.
|
||||||
|
//
|
||||||
|
// =====================================================================
|
||||||
|
// WHAT THIS PROVES (and what it does NOT)
|
||||||
|
// =====================================================================
|
||||||
|
//
|
||||||
|
// This file builds the FORS bottom of an SLH-DSA signature from a
|
||||||
|
// t-of-n committee such that:
|
||||||
|
//
|
||||||
|
// * NO global seed / full secret key is ever reconstructed. There is
|
||||||
|
// no master seed in this construction at all: the FORS leaf secrets
|
||||||
|
// are secret-shared DIRECTLY (jointly-random, byte-wise GF(257)
|
||||||
|
// Shamir). At sign time the combiner interpolates ONLY the k
|
||||||
|
// message-selected one-time leaf secrets --- each exactly n bytes
|
||||||
|
// (one leaf), guarded by assertLeafWidth so the path is structurally
|
||||||
|
// incapable of widening to the SeedSize-byte master. (Approach B of
|
||||||
|
// the paper: "threshold leaf opening".)
|
||||||
|
//
|
||||||
|
// * The assembled FORS signature is BYTE-IDENTICAL to the centralized
|
||||||
|
// FIPS 205 forsSign output on the same leaf material, and its FORS
|
||||||
|
// public key reconstructs via the UNMODIFIED FIPS 205 Algorithm 17
|
||||||
|
// (forsPkFromSig) --- the verifier's own FORS step, proven
|
||||||
|
// byte-equal to cloudflare/circl by TestSlhdsaInternal_ByteEqualToCirclSign.
|
||||||
|
//
|
||||||
|
// * One-time material is BURNED: every opened leaf address is recorded
|
||||||
|
// in the BurnLedger; a second open of the same FORS keypair under a
|
||||||
|
// different message digest is refused as a slashable equivocation.
|
||||||
|
//
|
||||||
|
// What it does NOT do: it is NOT a full SLH-DSA signature. A full
|
||||||
|
// signature additionally needs the hypertree --- d WOTS+ signatures plus
|
||||||
|
// XMSS authentication paths whose sibling nodes are secret-derived WOTS+
|
||||||
|
// public keys over a 2^h-leaf tree. Those siblings cannot be opened at
|
||||||
|
// sign time (that would expose ~2^h' one-time WOTS+ keypairs and destroy
|
||||||
|
// one-time security) and cannot be precomputed without either a 2^h
|
||||||
|
// materialisation (infeasible for h = 63..68) or a keygen-time MPC over
|
||||||
|
// SHAKE (the Cozzo-Smart wall). Hence the FULL Track-B signer fails
|
||||||
|
// closed (threshold_noreconstruct.go); THIS file is a proven COMPONENT.
|
||||||
|
//
|
||||||
|
// =====================================================================
|
||||||
|
// KEYGEN HONESTY
|
||||||
|
// =====================================================================
|
||||||
|
//
|
||||||
|
// The PUBLIC FORS material (auth paths + pkFORS) is a deterministic hash
|
||||||
|
// of the secret leaves; computing it from secret-shared leaves without
|
||||||
|
// exposing the unopened leaves requires either an MPC over F at keygen
|
||||||
|
// or a setup-TCB dealer. DistributedForsSetup below uses a setup-TCB
|
||||||
|
// dealer (it knows the genuine leaves for the keypair it is provisioning
|
||||||
|
// and shares them) --- this is the SAME "dealer-in-the-TCB-for-setup-only"
|
||||||
|
// pattern the production Magnetar v1.0 setup uses, and it is honestly the
|
||||||
|
// keygen obstruction, documented in the paper. The NOVELTY proven here
|
||||||
|
// is no-reconstruct SIGNING: once setup returns, no party holds the
|
||||||
|
// leaves; the combiner opens only the per-message selected one-time
|
||||||
|
// leaves and never the full FORS key.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Customisation tag for the FORS-open commit. Distinct from every other
|
||||||
|
// Magnetar tag so a cross-protocol replay deterministically mismatches.
|
||||||
|
const tagForsOpenCommit = "MAGNETAR-FORS-THRESHOLD-OPEN-V1"
|
||||||
|
|
||||||
|
// Errors specific to the distributed FORS-leaf opening.
|
||||||
|
var (
|
||||||
|
// ErrForsSetupParams is returned when the FORS open is invoked for a
|
||||||
|
// parameter set the internal FIPS 205 engine does not implement.
|
||||||
|
ErrForsSetupParams = errors.New("magnetar/fors-open: parameter set not implemented by the FIPS 205 engine")
|
||||||
|
|
||||||
|
// ErrForsQuorum is returned when fewer than threshold partial opens
|
||||||
|
// are presented for some selected leaf.
|
||||||
|
ErrForsQuorum = errors.New("magnetar/fors-open: insufficient partial opens for a selected leaf")
|
||||||
|
|
||||||
|
// ErrForsShape is returned when a partial-open payload has the wrong
|
||||||
|
// dimensions.
|
||||||
|
ErrForsShape = errors.New("magnetar/fors-open: partial-open payload has wrong shape")
|
||||||
|
|
||||||
|
// ErrForsCommitMismatch is returned when a partial open does not
|
||||||
|
// re-derive its commit against the published (addr, digest).
|
||||||
|
ErrForsCommitMismatch = errors.New("magnetar/fors-open: partial-open commit does not re-derive")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForsKeypairAddr identifies one FORS keypair in the SLH-DSA hypertree:
|
||||||
|
// the XMSS tree address (3 words, FIPS 205 §4.2) and the keypair (XMSS
|
||||||
|
// leaf) index. In a full signature this is selected by R from the
|
||||||
|
// message; the component protocol takes it as an explicit input.
|
||||||
|
type ForsKeypairAddr struct {
|
||||||
|
IdxTree [3]uint32
|
||||||
|
IdxLeaf uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// forsAddrBuf builds the FIPS 205 address template forsSign / forsPkFromSig
|
||||||
|
// expect for this keypair (layer 0, type FORS-tree, keypair address set).
|
||||||
|
func (a ForsKeypairAddr) forsAddrBuf() adrsBuf {
|
||||||
|
var adr adrsBuf
|
||||||
|
adr.setLayerAddress(0)
|
||||||
|
adr.setTreeAddress(a.IdxTree)
|
||||||
|
adr.setTypeAndClear(adrsTypeForsTree)
|
||||||
|
adr.setKeyPairAddress(a.IdxLeaf)
|
||||||
|
return adr
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForsPublicMaterial is everything the combiner and any third-party
|
||||||
|
// verifier need that carries NO secret: the keypair address, the message
|
||||||
|
// digest md (which selects the leaves), the k selected leaf indices, the
|
||||||
|
// k public authentication paths, and the published FORS public key.
|
||||||
|
//
|
||||||
|
// Published at setup. A verifier checks an assembled FORS signature by
|
||||||
|
// running stock FIPS 205 forsPkFromSig and comparing to PkFors.
|
||||||
|
type ForsPublicMaterial struct {
|
||||||
|
Params *Params
|
||||||
|
Addr ForsKeypairAddr
|
||||||
|
Digest []byte // md (>= ceil(k*a/8) bytes); public
|
||||||
|
Indices []uint32 // k selected leaf indices, derived from md (public)
|
||||||
|
AuthPaths [][]byte // k auth paths, each a*n bytes (public)
|
||||||
|
PkFors []byte // n-byte FORS public key T_k(roots) (public)
|
||||||
|
PkSeed []byte // n-byte FIPS 205 public seed (public)
|
||||||
|
Threshold int // t: leaves reconstruct from any t partial opens
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForsLeafShareMatrix is the secret-shared FORS leaf material produced by
|
||||||
|
// DistributedForsSetup. Shares[partyIdx][forsTree] is party (partyIdx+1)'s
|
||||||
|
// byte-wise GF(257) Shamir share of the n-byte leaf secret selected in
|
||||||
|
// FORS sub-tree forsTree. NO party holds more than its own row; the
|
||||||
|
// genuine leaves exist only inside the setup-TCB dealer and are erased
|
||||||
|
// before this matrix is returned.
|
||||||
|
type ForsLeafShareMatrix struct {
|
||||||
|
Params *Params
|
||||||
|
Addr ForsKeypairAddr
|
||||||
|
N int // committee size
|
||||||
|
T int // threshold
|
||||||
|
// Shares[partyIdx][forsTree] is one party's share of one leaf secret.
|
||||||
|
Shares [][]thbsseShare
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartyRow returns party (partyIndex, 1-based) view: its column of leaf
|
||||||
|
// shares across the k FORS sub-trees. This is the ONLY share material a
|
||||||
|
// party holds; it never sees other parties' rows or the genuine leaves.
|
||||||
|
func (m *ForsLeafShareMatrix) PartyRow(partyIndex uint32) ([]thbsseShare, error) {
|
||||||
|
if partyIndex < 1 || int(partyIndex) > m.N {
|
||||||
|
return nil, ErrForsShape
|
||||||
|
}
|
||||||
|
return m.Shares[partyIndex-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forsSelectedIndices replays FIPS 205 §8.3 Algorithm 16 index parsing:
|
||||||
|
// for digest md it returns the k leaf indices (one per FORS sub-tree)
|
||||||
|
// selected for signing. Public function of md only.
|
||||||
|
func forsSelectedIndices(p *internalParams, digest []byte) []uint32 {
|
||||||
|
indices := make([]uint32, p.k)
|
||||||
|
in, bits, total := 0, uint32(0), uint32(0)
|
||||||
|
maskA := (uint32(1) << p.a) - 1
|
||||||
|
for i := uint32(0); i < p.k; i++ {
|
||||||
|
for bits < p.a {
|
||||||
|
total = (total << 8) + uint32(digest[in])
|
||||||
|
in++
|
||||||
|
bits += 8
|
||||||
|
}
|
||||||
|
bits -= p.a
|
||||||
|
indices[i] = (total >> bits) & maskA
|
||||||
|
}
|
||||||
|
return indices
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistributedForsSetup is the setup-TCB dealer for one FORS keypair. It
|
||||||
|
// derives the genuine FORS leaf material from a setup seed (the dealer is
|
||||||
|
// in the TCB FOR SETUP ONLY), shares the k message-selected leaf secrets
|
||||||
|
// across the committee, publishes the auth paths + pkFORS, and erases the
|
||||||
|
// genuine leaves before returning.
|
||||||
|
//
|
||||||
|
// Production deployments replace this with a dealerless DKG + MPC-over-F
|
||||||
|
// keygen (the documented keygen obstruction); the SIGN-side wire shape
|
||||||
|
// (ForsLeafShareMatrix + ForsPublicMaterial) is unchanged.
|
||||||
|
//
|
||||||
|
// seed is a FIPS 205 SeedSize-byte scheme seed; addr is the FORS keypair
|
||||||
|
// to provision; digest is md (selects the leaves); (n, t) is the
|
||||||
|
// committee. coeffStream feeds the Shamir polynomials (stretched if short).
|
||||||
|
func DistributedForsSetup(
|
||||||
|
params *Params,
|
||||||
|
seed []byte,
|
||||||
|
addr ForsKeypairAddr,
|
||||||
|
digest []byte,
|
||||||
|
n, t int,
|
||||||
|
coeffStream []byte,
|
||||||
|
) (*ForsLeafShareMatrix, *ForsPublicMaterial, error) {
|
||||||
|
if err := params.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
internal, ok := internalParamsForMode(params.Mode)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, ErrForsSetupParams
|
||||||
|
}
|
||||||
|
if t < 1 || n < t || n > MaxCommittee257 {
|
||||||
|
return nil, nil, ErrInvalidThreshold
|
||||||
|
}
|
||||||
|
if len(seed) != params.SeedSize {
|
||||||
|
return nil, nil, ErrSeedSize
|
||||||
|
}
|
||||||
|
if len(digest) < int((internal.k*internal.a+7)/8) {
|
||||||
|
return nil, nil, ErrForsShape
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SETUP TCB: derive the genuine FORS material from the seed. ---
|
||||||
|
// Expand the seed to (skSeed || skPrf || pkSeed) exactly as FIPS 205
|
||||||
|
// §10.1 DeriveKey does, then build the secret-side PRF closure. This
|
||||||
|
// is the ONLY place the seed expansion exists; it lives inside this
|
||||||
|
// dealer and is erased before return.
|
||||||
|
derived := make([]byte, 3*internal.n)
|
||||||
|
shakeIntoCat(derived, seed)
|
||||||
|
skSeedSeg := derived[:internal.n]
|
||||||
|
pkSeed := append([]byte(nil), derived[2*internal.n:3*internal.n]...)
|
||||||
|
prfOut := makePRFClosure(internal, pkSeed, skSeedSeg)
|
||||||
|
|
||||||
|
// Centralized reference FORS signature on md at addr: this gives both
|
||||||
|
// the genuine leaf secrets (to share) and the public auth paths (to
|
||||||
|
// publish), byte-for-byte as FIPS 205 forsSign emits them.
|
||||||
|
adr := addr.forsAddrBuf()
|
||||||
|
refSig := make([]byte, internal.forsSigSize())
|
||||||
|
forsSign(internal, refSig, pkSeed, &adr, digest, prfOut)
|
||||||
|
|
||||||
|
// Genuine pkFORS via the verifier's own Algorithm 17.
|
||||||
|
pkFors := make([]byte, internal.n)
|
||||||
|
forsPkFromSig(internal, pkFors, pkSeed, &adr, digest, refSig)
|
||||||
|
|
||||||
|
indices := forsSelectedIndices(internal, digest)
|
||||||
|
|
||||||
|
pairSize := int(internal.forsPairSize())
|
||||||
|
leafN := int(internal.n)
|
||||||
|
authLen := int(internal.forsAuthPathSize())
|
||||||
|
|
||||||
|
// Extract genuine leaves + public auth paths from refSig.
|
||||||
|
authPaths := make([][]byte, internal.k)
|
||||||
|
shares := make([][]thbsseShare, n) // shares[party][forsTree]
|
||||||
|
for p := 0; p < n; p++ {
|
||||||
|
shares[p] = make([]thbsseShare, internal.k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share each leaf secret independently (byte-wise GF(257) Shamir).
|
||||||
|
// CRITICAL: each leaf gets an INDEPENDENT Shamir coefficient stream
|
||||||
|
// (domain-separated by leaf index). Reusing coefficients across leaves
|
||||||
|
// would let an adversary holding one share of two leaves learn their
|
||||||
|
// GF(257) difference (the coefficient terms cancel) --- a real leak.
|
||||||
|
needed := (t - 1) * leafN * 2
|
||||||
|
if needed < 2 {
|
||||||
|
needed = 2
|
||||||
|
}
|
||||||
|
for i := 0; i < int(internal.k); i++ {
|
||||||
|
skOff := i * pairSize
|
||||||
|
leaf := refSig[skOff : skOff+leafN] // genuine leaf secret (n bytes)
|
||||||
|
authPaths[i] = append([]byte(nil), refSig[skOff+leafN:skOff+leafN+authLen]...)
|
||||||
|
|
||||||
|
// Per-leaf independent coefficient stream: cSHAKE256(coeffStream ||
|
||||||
|
// leaf_index). Distinct customisation-bound output per leaf.
|
||||||
|
csSeed := make([]byte, 0, len(coeffStream)+4)
|
||||||
|
csSeed = append(csSeed, coeffStream...)
|
||||||
|
csSeed = binary.BigEndian.AppendUint32(csSeed, uint32(i))
|
||||||
|
cs := cshake256(csSeed, needed, tagForsOpenCommit)
|
||||||
|
|
||||||
|
gf := make([]uint16, leafN)
|
||||||
|
for b := 0; b < leafN; b++ {
|
||||||
|
gf[b] = uint16(leaf[b])
|
||||||
|
}
|
||||||
|
leafShares, err := thbsseDealRandomGF(gf, n, t, cs)
|
||||||
|
if err != nil {
|
||||||
|
zeroizeBytes(derived)
|
||||||
|
zeroizeBytes(refSig)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for p := 0; p < n; p++ {
|
||||||
|
shares[p][i] = leafShares[p]
|
||||||
|
}
|
||||||
|
zeroizeU16Slice(gf)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub := &ForsPublicMaterial{
|
||||||
|
Params: params,
|
||||||
|
Addr: addr,
|
||||||
|
Digest: append([]byte(nil), digest...),
|
||||||
|
Indices: indices,
|
||||||
|
AuthPaths: authPaths,
|
||||||
|
PkFors: pkFors,
|
||||||
|
PkSeed: pkSeed,
|
||||||
|
Threshold: t,
|
||||||
|
}
|
||||||
|
mat := &ForsLeafShareMatrix{Params: params, Addr: addr, N: n, T: t, Shares: shares}
|
||||||
|
|
||||||
|
// Erase the setup-TCB secret material. After this point no genuine
|
||||||
|
// leaf, no skSeed, no seed expansion exists anywhere; only the shares
|
||||||
|
// (held one-row-per-party) and the public material remain.
|
||||||
|
zeroizeBytes(refSig)
|
||||||
|
zeroizeBytes(derived)
|
||||||
|
|
||||||
|
return mat, pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForsPartialOpen is one party's release of its shares of the k selected
|
||||||
|
// one-time leaf secrets, bound by a commit to (addr, digest, party) so a
|
||||||
|
// cross-context replay deterministically mismatches and so the burn
|
||||||
|
// ledger can attribute reuse. The LeafShares carry the party's GF(257)
|
||||||
|
// Shamir value for each selected leaf; the share at a single evaluation
|
||||||
|
// point is information-theoretically independent of the leaf for any
|
||||||
|
// party holding < t shares.
|
||||||
|
type ForsPartialOpen struct {
|
||||||
|
PartyIndex uint32 // 1-based committee position == Shamir eval point
|
||||||
|
Commit [32]byte // cSHAKE256(addr || digest || party || leaf-shares)
|
||||||
|
LeafShares [][]uint16 // [forsTree][n] GF(257) share values at PartyIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartialOpen builds party (partyIndex)'s ForsPartialOpen from its row of
|
||||||
|
// the share matrix. The commit binds the release to the public (addr,
|
||||||
|
// digest) so the same shares cannot be replayed for a different keypair
|
||||||
|
// or message.
|
||||||
|
func (m *ForsLeafShareMatrix) PartialOpen(partyIndex uint32, digest []byte) (ForsPartialOpen, error) {
|
||||||
|
row, err := m.PartyRow(partyIndex)
|
||||||
|
if err != nil {
|
||||||
|
return ForsPartialOpen{}, err
|
||||||
|
}
|
||||||
|
leafShares := make([][]uint16, len(row))
|
||||||
|
for i := range row {
|
||||||
|
leafShares[i] = append([]uint16(nil), row[i].Y...)
|
||||||
|
}
|
||||||
|
open := ForsPartialOpen{PartyIndex: partyIndex, LeafShares: leafShares}
|
||||||
|
open.Commit = forsOpenCommit(m.Addr, digest, partyIndex, leafShares)
|
||||||
|
return open, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forsOpenCommit binds a partial open to (addr, digest, party, shares).
|
||||||
|
func forsOpenCommit(addr ForsKeypairAddr, digest []byte, party uint32, leafShares [][]uint16) [32]byte {
|
||||||
|
buf := make([]byte, 0, 64+len(digest))
|
||||||
|
for _, w := range addr.IdxTree {
|
||||||
|
buf = binary.BigEndian.AppendUint32(buf, w)
|
||||||
|
}
|
||||||
|
buf = binary.BigEndian.AppendUint32(buf, addr.IdxLeaf)
|
||||||
|
buf = append(buf, digest...)
|
||||||
|
buf = binary.BigEndian.AppendUint32(buf, party)
|
||||||
|
for _, ls := range leafShares {
|
||||||
|
for _, v := range ls {
|
||||||
|
buf = binary.BigEndian.AppendUint16(buf, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out [32]byte
|
||||||
|
copy(out[:], cshake256(buf, 32, tagForsOpenCommit))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// digest32 hashes md into the 32-byte burn-ledger digest key.
|
||||||
|
func digest32(md []byte) [32]byte {
|
||||||
|
var out [32]byte
|
||||||
|
copy(out[:], cshake256(md, 32, tagForsOpenCommit))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenForsThreshold is the PUBLIC combiner. From the public material and
|
||||||
|
// >= t partial opens, it interpolates ONLY the k message-selected one-time
|
||||||
|
// leaf secrets --- each exactly one leaf wide, guarded by assertLeafWidth
|
||||||
|
// so no global-seed reconstruction is possible --- assembles the FORS
|
||||||
|
// signature, burns the opened leaf addresses, and returns the FORS
|
||||||
|
// signature bytes.
|
||||||
|
//
|
||||||
|
// The returned bytes are byte-identical to the centralized FIPS 205
|
||||||
|
// forsSign output, and forsPkFromSig(returned) == pub.PkFors under the
|
||||||
|
// unmodified FIPS 205 Algorithm 17.
|
||||||
|
//
|
||||||
|
// ledger enforces one-time/few-time safety: each selected FORS leaf
|
||||||
|
// address is burned for the message digest; a second open of the same
|
||||||
|
// keypair under a different digest fails with *BurnConflict (constraint 3).
|
||||||
|
// Pass a fresh *BurnLedger for an isolated open; pass a persistent one to
|
||||||
|
// enforce no-reuse across messages.
|
||||||
|
func OpenForsThreshold(
|
||||||
|
pub *ForsPublicMaterial,
|
||||||
|
partials []ForsPartialOpen,
|
||||||
|
ledger *BurnLedger,
|
||||||
|
) ([]byte, error) {
|
||||||
|
if pub == nil {
|
||||||
|
return nil, errors.New("magnetar/fors-open: nil public material")
|
||||||
|
}
|
||||||
|
params := pub.Params
|
||||||
|
if err := params.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
internal, ok := internalParamsForMode(params.Mode)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrForsSetupParams
|
||||||
|
}
|
||||||
|
if ledger == nil {
|
||||||
|
return nil, errors.New("magnetar/fors-open: nil burn ledger (one-time safety requires a ledger)")
|
||||||
|
}
|
||||||
|
leafN := int(internal.n)
|
||||||
|
authLen := int(internal.forsAuthPathSize())
|
||||||
|
pairSize := int(internal.forsPairSize())
|
||||||
|
k := int(internal.k)
|
||||||
|
if len(pub.AuthPaths) != k {
|
||||||
|
return nil, ErrForsShape
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate partial opens against their commits and collect, per FORS
|
||||||
|
// sub-tree, the (eval-point, share-vector) pairs.
|
||||||
|
type pt struct {
|
||||||
|
x uint32
|
||||||
|
y []uint16
|
||||||
|
}
|
||||||
|
collected := make([][]pt, k)
|
||||||
|
for j := 0; j < k; j++ {
|
||||||
|
collected[j] = make([]pt, 0, len(partials))
|
||||||
|
}
|
||||||
|
seenParty := make(map[uint32]struct{}, len(partials))
|
||||||
|
for _, po := range partials {
|
||||||
|
if po.PartyIndex == 0 {
|
||||||
|
return nil, ErrForsShape
|
||||||
|
}
|
||||||
|
if _, dup := seenParty[po.PartyIndex]; dup {
|
||||||
|
continue // ignore duplicate party submissions
|
||||||
|
}
|
||||||
|
if len(po.LeafShares) != k {
|
||||||
|
return nil, ErrForsShape
|
||||||
|
}
|
||||||
|
for j := 0; j < k; j++ {
|
||||||
|
if len(po.LeafShares[j]) != leafN {
|
||||||
|
return nil, ErrForsShape
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := forsOpenCommit(pub.Addr, pub.Digest, po.PartyIndex, po.LeafShares)
|
||||||
|
if want != po.Commit {
|
||||||
|
return nil, ErrForsCommitMismatch
|
||||||
|
}
|
||||||
|
seenParty[po.PartyIndex] = struct{}{}
|
||||||
|
for j := 0; j < k; j++ {
|
||||||
|
collected[j] = append(collected[j], pt{x: po.PartyIndex, y: po.LeafShares[j]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mdDigest := digest32(pub.Digest)
|
||||||
|
forsSig := make([]byte, internal.forsSigSize())
|
||||||
|
|
||||||
|
for j := 0; j < k; j++ {
|
||||||
|
// CONSTRAINT 1 (fail-closed): we interpolate exactly one leaf
|
||||||
|
// (leafN bytes), never the SeedSize master. assertLeafWidth makes
|
||||||
|
// a widening refactor fault immediately.
|
||||||
|
if err := assertLeafWidth(params, leafN); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pub.Threshold < 1 || len(collected[j]) < pub.Threshold {
|
||||||
|
return nil, ErrForsQuorum
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONSTRAINT 3 (fail-closed): burn the one-time FORS leaf address
|
||||||
|
// for this digest before releasing the opened secret. A second
|
||||||
|
// open of the same keypair sub-tree under a different message
|
||||||
|
// digest is a slashable equivocation.
|
||||||
|
addr := OneTimeAddr{
|
||||||
|
Kind: KindForsFewTime,
|
||||||
|
Layer: 0,
|
||||||
|
Tree: pub.Addr.IdxTree[0],
|
||||||
|
Leaf: pub.Addr.IdxLeaf,
|
||||||
|
ForsTree: uint32(j),
|
||||||
|
}
|
||||||
|
if err := ledger.Burn(addr, mdDigest); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lagrange-interpolate this single leaf secret over GF(257) from
|
||||||
|
// any t of the collected shares (deterministic public combiner).
|
||||||
|
shares := make([]thbsseShare, pub.Threshold)
|
||||||
|
for s := 0; s < pub.Threshold; s++ {
|
||||||
|
shares[s] = thbsseShare{X: collected[j][s].x, Y: collected[j][s].y}
|
||||||
|
}
|
||||||
|
leaf, err := thbsseReconstructGF(shares, leafN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write leaf secret + public auth path into the FORS signature.
|
||||||
|
skOff := j * pairSize
|
||||||
|
for b := 0; b < leafN; b++ {
|
||||||
|
forsSig[skOff+b] = byte(leaf[b])
|
||||||
|
}
|
||||||
|
copy(forsSig[skOff+leafN:skOff+leafN+authLen], pub.AuthPaths[j])
|
||||||
|
zeroizeU16Slice(leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
return forsSig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyForsThreshold checks an assembled FORS signature against the
|
||||||
|
// published FORS public key using the UNMODIFIED FIPS 205 Algorithm 17
|
||||||
|
// (forsPkFromSig). This is exactly the verifier-side FORS step; it is
|
||||||
|
// proven byte-equal to cloudflare/circl by TestSlhdsaInternal_ByteEqualToCirclSign.
|
||||||
|
//
|
||||||
|
// Returns true iff forsPkFromSig(forsSig) == pub.PkFors.
|
||||||
|
func VerifyForsThreshold(pub *ForsPublicMaterial, forsSig []byte) (bool, error) {
|
||||||
|
if pub == nil {
|
||||||
|
return false, errors.New("magnetar/fors-open: nil public material")
|
||||||
|
}
|
||||||
|
internal, ok := internalParamsForMode(pub.Params.Mode)
|
||||||
|
if !ok {
|
||||||
|
return false, ErrForsSetupParams
|
||||||
|
}
|
||||||
|
if len(forsSig) != int(internal.forsSigSize()) {
|
||||||
|
return false, ErrForsShape
|
||||||
|
}
|
||||||
|
if len(pub.PkFors) != int(internal.n) {
|
||||||
|
return false, ErrForsShape
|
||||||
|
}
|
||||||
|
if len(pub.PkSeed) != int(internal.n) {
|
||||||
|
return false, ErrForsShape
|
||||||
|
}
|
||||||
|
adr := pub.Addr.forsAddrBuf()
|
||||||
|
got := make([]byte, internal.n)
|
||||||
|
forsPkFromSig(internal, got, pub.PkSeed, &adr, pub.Digest, forsSig)
|
||||||
|
return ctEqualBytes(got, pub.PkFors), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFullOrErr is a tiny helper for setup callers that want to source
|
||||||
|
// the Shamir coefficient stream from an io.Reader. Returns ErrShortRand
|
||||||
|
// on a short read.
|
||||||
|
func readFullOrErr(rng io.Reader, n int) ([]byte, error) {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(rng, buf); err != nil {
|
||||||
|
return nil, ErrShortRand
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
package magnetar
|
||||||
|
|
||||||
|
// fors_threshold_open_test.go --- proves the distributed FORS-leaf
|
||||||
|
// threshold opening is (a) byte-identical to centralized FIPS 205
|
||||||
|
// forsSign, (b) accepted by the unmodified FIPS 205 Algorithm 17
|
||||||
|
// verifier, (c) no-reconstruct (only one-leaf-wide interpolation), and
|
||||||
|
// (d) one-time-safe (burn ledger refuses keypair reuse).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// referenceFors recomputes the centralized FIPS 205 FORS signature on md
|
||||||
|
// at addr from seed --- the byte-identity oracle. Mirrors the setup-TCB
|
||||||
|
// dealer's derivation, used here only as a test reference.
|
||||||
|
func referenceFors(t *testing.T, params *Params, seed []byte, addr ForsKeypairAddr, md []byte) (refSig, pkSeed []byte) {
|
||||||
|
t.Helper()
|
||||||
|
internal, ok := internalParamsForMode(params.Mode)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no internal params for mode %v", params.Mode)
|
||||||
|
}
|
||||||
|
derived := make([]byte, 3*internal.n)
|
||||||
|
shakeIntoCat(derived, seed)
|
||||||
|
pkSeed = append([]byte(nil), derived[2*internal.n:3*internal.n]...)
|
||||||
|
prfOut := makePRFClosure(internal, pkSeed, derived[:internal.n])
|
||||||
|
adr := addr.forsAddrBuf()
|
||||||
|
refSig = make([]byte, internal.forsSigSize())
|
||||||
|
forsSign(internal, refSig, pkSeed, &adr, md, prfOut)
|
||||||
|
return refSig, pkSeed
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForsThreshold_NoReconstruct_StockVerify is the headline proof:
|
||||||
|
// a t-of-n threshold opening of the FORS leaves yields a FORS signature
|
||||||
|
// byte-identical to the centralized one, verifies under stock FIPS 205
|
||||||
|
// Algorithm 17, and is produced WITHOUT any seed reconstruction.
|
||||||
|
func TestForsThreshold_NoReconstruct_StockVerify(t *testing.T) {
|
||||||
|
skipUnderRace(t)
|
||||||
|
for _, mode := range []Mode{ModeM192s, ModeM256s} {
|
||||||
|
params := MustParamsFor(mode)
|
||||||
|
internal, _ := internalParamsForMode(mode)
|
||||||
|
|
||||||
|
seed := make([]byte, params.SeedSize)
|
||||||
|
_, _ = deterministicReader([]byte("magnetar-fors-threshold-seed")).Read(seed)
|
||||||
|
md := make([]byte, internal.m)
|
||||||
|
_, _ = deterministicReader([]byte("magnetar-fors-threshold-md")).Read(md)
|
||||||
|
coeff := make([]byte, 4096)
|
||||||
|
_, _ = deterministicReader([]byte("magnetar-fors-threshold-coeff")).Read(coeff)
|
||||||
|
|
||||||
|
addr := ForsKeypairAddr{IdxTree: [3]uint32{0, 0, 0}, IdxLeaf: 3}
|
||||||
|
n, th := 5, 3
|
||||||
|
|
||||||
|
mat, pub, err := DistributedForsSetup(params, seed, addr, md, n, th, coeff)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%v] setup: %v", mode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference (centralized) FORS signature on the same material.
|
||||||
|
refSig, _ := referenceFors(t, params, seed, addr, md)
|
||||||
|
|
||||||
|
// Threshold open with a STRICT t-subset {1,3,5} (proves t-of-n,
|
||||||
|
// not all-n).
|
||||||
|
var partials []ForsPartialOpen
|
||||||
|
for _, p := range []uint32{1, 3, 5} {
|
||||||
|
po, err := mat.PartialOpen(p, md)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%v] PartialOpen(%d): %v", mode, p, err)
|
||||||
|
}
|
||||||
|
partials = append(partials, po)
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger := NewBurnLedger()
|
||||||
|
forsSig, err := OpenForsThreshold(pub, partials, ledger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%v] OpenForsThreshold: %v", mode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) Byte-identity to centralized FIPS 205 forsSign.
|
||||||
|
if !bytes.Equal(forsSig, refSig) {
|
||||||
|
t.Fatalf("[%v] threshold FORS sig != centralized forsSign (byte-identity broken)", mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) Stock FIPS 205 Algorithm 17 verification accepts.
|
||||||
|
ok, err := VerifyForsThreshold(pub, forsSig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%v] VerifyForsThreshold: %v", mode, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("[%v] stock FIPS 205 forsPkFromSig rejected the threshold FORS sig", mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) A DISJOINT t-subset {2,3,4} produces the SAME bytes
|
||||||
|
// (deterministic public combiner). Re-burn under same digest is
|
||||||
|
// idempotent so the same ledger is reused.
|
||||||
|
var partials2 []ForsPartialOpen
|
||||||
|
for _, p := range []uint32{2, 3, 4} {
|
||||||
|
po, _ := mat.PartialOpen(p, md)
|
||||||
|
partials2 = append(partials2, po)
|
||||||
|
}
|
||||||
|
forsSig2, err := OpenForsThreshold(pub, partials2, ledger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%v] OpenForsThreshold disjoint subset: %v", mode, err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(forsSig, forsSig2) {
|
||||||
|
t.Fatalf("[%v] disjoint t-subsets produced different signatures", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForsThreshold_OneTimeSafety_BurnRefusal proves constraint 3: the
|
||||||
|
// same FORS keypair signing a SECOND, distinct message is refused as a
|
||||||
|
// slashable one-time-reuse equivocation.
|
||||||
|
func TestForsThreshold_OneTimeSafety_BurnRefusal(t *testing.T) {
|
||||||
|
skipUnderRace(t)
|
||||||
|
params := MustParamsFor(ModeM192s)
|
||||||
|
internal, _ := internalParamsForMode(ModeM192s)
|
||||||
|
|
||||||
|
seed := make([]byte, params.SeedSize)
|
||||||
|
_, _ = deterministicReader([]byte("burn-seed")).Read(seed)
|
||||||
|
coeff := make([]byte, 4096)
|
||||||
|
_, _ = deterministicReader([]byte("burn-coeff")).Read(coeff)
|
||||||
|
addr := ForsKeypairAddr{IdxTree: [3]uint32{0, 0, 0}, IdxLeaf: 7}
|
||||||
|
|
||||||
|
md1 := make([]byte, internal.m)
|
||||||
|
_, _ = deterministicReader([]byte("burn-md-1")).Read(md1)
|
||||||
|
md2 := make([]byte, internal.m)
|
||||||
|
_, _ = deterministicReader([]byte("burn-md-2")).Read(md2)
|
||||||
|
|
||||||
|
ledger := NewBurnLedger() // shared across both messages
|
||||||
|
|
||||||
|
// First message: signs fine, burns the keypair.
|
||||||
|
mat1, pub1, err := DistributedForsSetup(params, seed, addr, md1, 5, 3, coeff)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup md1: %v", err)
|
||||||
|
}
|
||||||
|
var p1 []ForsPartialOpen
|
||||||
|
for _, p := range []uint32{1, 2, 3} {
|
||||||
|
po, _ := mat1.PartialOpen(p, md1)
|
||||||
|
p1 = append(p1, po)
|
||||||
|
}
|
||||||
|
if _, err := OpenForsThreshold(pub1, p1, ledger); err != nil {
|
||||||
|
t.Fatalf("first open should succeed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second, distinct message on the SAME keypair: MUST be refused.
|
||||||
|
mat2, pub2, err := DistributedForsSetup(params, seed, addr, md2, 5, 3, coeff)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup md2: %v", err)
|
||||||
|
}
|
||||||
|
var p2 []ForsPartialOpen
|
||||||
|
for _, p := range []uint32{1, 2, 3} {
|
||||||
|
po, _ := mat2.PartialOpen(p, md2)
|
||||||
|
p2 = append(p2, po)
|
||||||
|
}
|
||||||
|
_, err = OpenForsThreshold(pub2, p2, ledger)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("one-time safety FAILED: second open of same keypair under a different digest was accepted")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrOneTimeReuse) {
|
||||||
|
t.Fatalf("expected ErrOneTimeReuse, got %v", err)
|
||||||
|
}
|
||||||
|
var conflict *BurnConflict
|
||||||
|
if !errors.As(err, &conflict) {
|
||||||
|
t.Fatalf("expected *BurnConflict slashable evidence, got %T", err)
|
||||||
|
}
|
||||||
|
if conflict.Prior == conflict.New {
|
||||||
|
t.Fatalf("burn conflict must carry two distinct digests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForsThreshold_CommitMismatch_Rejected proves a tampered partial
|
||||||
|
// open (wrong shares) is rejected by the commit re-derivation.
|
||||||
|
func TestForsThreshold_CommitMismatch_Rejected(t *testing.T) {
|
||||||
|
skipUnderRace(t)
|
||||||
|
params := MustParamsFor(ModeM192s)
|
||||||
|
internal, _ := internalParamsForMode(ModeM192s)
|
||||||
|
seed := make([]byte, params.SeedSize)
|
||||||
|
_, _ = deterministicReader([]byte("commit-seed")).Read(seed)
|
||||||
|
coeff := make([]byte, 4096)
|
||||||
|
_, _ = deterministicReader([]byte("commit-coeff")).Read(coeff)
|
||||||
|
md := make([]byte, internal.m)
|
||||||
|
_, _ = deterministicReader([]byte("commit-md")).Read(md)
|
||||||
|
addr := ForsKeypairAddr{IdxTree: [3]uint32{0, 0, 0}, IdxLeaf: 1}
|
||||||
|
|
||||||
|
mat, pub, err := DistributedForsSetup(params, seed, addr, md, 5, 3, coeff)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
var partials []ForsPartialOpen
|
||||||
|
for _, p := range []uint32{1, 2, 3} {
|
||||||
|
po, _ := mat.PartialOpen(p, md)
|
||||||
|
partials = append(partials, po)
|
||||||
|
}
|
||||||
|
// Tamper one share value WITHOUT updating the commit.
|
||||||
|
partials[1].LeafShares[0][0] ^= 0x01
|
||||||
|
_, err = OpenForsThreshold(pub, partials, NewBurnLedger())
|
||||||
|
if !errors.Is(err, ErrForsCommitMismatch) {
|
||||||
|
t.Fatalf("expected ErrForsCommitMismatch for tampered share, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
package magnetar
|
||||||
|
|
||||||
|
// threshold_noreconstruct.go --- Magnetar Track B: the NO-RECONSTRUCT
|
||||||
|
// threshold SLH-DSA interface, FAIL CLOSED.
|
||||||
|
//
|
||||||
|
// =====================================================================
|
||||||
|
// WHY THIS FILE EXISTS
|
||||||
|
// =====================================================================
|
||||||
|
//
|
||||||
|
// The Magnetar trustless-by-default law (owner-authoritative) splits
|
||||||
|
// threshold hash-based signing into two tracks:
|
||||||
|
//
|
||||||
|
// Track A --- Magnetar-Quorum (PRODUCTION, trustless TODAY).
|
||||||
|
// Validators hold INDEPENDENT ordinary FIPS 205 SLH-DSA keys; each
|
||||||
|
// signs the same Quasar subject; a P3Q STARK proves a >= policy
|
||||||
|
// weighted quorum of stock SLH-DSA signatures verified. No dealer,
|
||||||
|
// no DKG, no shared secret, no threshold SLH-DSA at all. Lives in
|
||||||
|
// the Track-A surface (sibling work), evidence kind
|
||||||
|
// "magnetar-p3q-slhdsa-rollup".
|
||||||
|
//
|
||||||
|
// Track B --- Magnetar-Threshold (RESEARCH ONLY, THIS FILE).
|
||||||
|
// A genuine t-of-n threshold SLH-DSA that emits ONE stock FIPS 205
|
||||||
|
// signature WITHOUT any party or aggregator ever reconstructing the
|
||||||
|
// global SLH-DSA seed / full secret key, even transiently. This is
|
||||||
|
// an OPEN research problem. Until no-reconstruct signing is proven,
|
||||||
|
// Track B MUST NOT be admitted to the POLARIS_MAX cert tier.
|
||||||
|
//
|
||||||
|
// This file encodes the Track-B INTERFACE and the FAIL-CLOSED gate. The
|
||||||
|
// signing entry returns ErrNoReconstructUnproven. NoReconstructProven()
|
||||||
|
// is hard-wired false. AdmitMagnetarThresholdToPolarisMax() refuses.
|
||||||
|
// Track B can therefore NEVER be silently admitted to POLARIS_MAX: a
|
||||||
|
// consumer that wires Track B into a strict cert tier gets an explicit
|
||||||
|
// error, not a degraded-trust signature.
|
||||||
|
//
|
||||||
|
// The ONE concretely-buildable, stock-FIPS-205-verifiable, genuinely
|
||||||
|
// no-reconstruct sub-protocol that DOES exist --- distributed FORS-leaf
|
||||||
|
// threshold opening --- lives in fors_threshold_open.go. It is a proven
|
||||||
|
// COMPONENT of a FORS-bottom signature, NOT a full SLH-DSA signature.
|
||||||
|
// It is deliberately NOT wired into Sign() below, because a full
|
||||||
|
// signature additionally needs the hypertree (d WOTS+ signatures + XMSS
|
||||||
|
// authentication paths), whose authentication-path siblings are
|
||||||
|
// secret-derived WOTS+ public keys over a 2^h-leaf tree --- the
|
||||||
|
// materialisation wall + keygen-MPC wall documented in the companion
|
||||||
|
// paper (papers/lp-magnetar-threshold-slhdsa).
|
||||||
|
//
|
||||||
|
// =====================================================================
|
||||||
|
// THE THREE HARD CONSTRAINTS (the acceptance gates)
|
||||||
|
// =====================================================================
|
||||||
|
//
|
||||||
|
// 1. NO-RECONSTRUCT. No aggregator or party ever reconstructs the
|
||||||
|
// global seed or full secret key, even transiently. (Contrast: the
|
||||||
|
// existing THBS-SE Combine path --- thbsse_assemble.go --- DOES
|
||||||
|
// reconstruct the full seed, into a buffer named derivedExpandInput
|
||||||
|
// / derivedMaterial; its "strict-atom" grep gate forbids the
|
||||||
|
// variable NAME "seed", not the reconstruction. THBS-SE is Track-A-
|
||||||
|
// adjacent reveal-and-aggregate, NOT a strict no-reconstruct lane.)
|
||||||
|
//
|
||||||
|
// 2. STOCK VERIFY. The final output verifies under ordinary FIPS 205
|
||||||
|
// SLH-DSA Verify (cloudflare/circl, unmodified). No verifier-side
|
||||||
|
// Magnetar code.
|
||||||
|
//
|
||||||
|
// 3. ONE-TIME SAFETY. Any revealed WOTS+/FORS one-time material is
|
||||||
|
// burned forever; no reusable secret leaks. Hash-based signatures
|
||||||
|
// are FRAGILE: a single WOTS+ chain-base reuse under two distinct
|
||||||
|
// message digits is a full WOTS+ key recovery, hence a hypertree
|
||||||
|
// forgery; FORS is few-time and degrades with reuse. The burn
|
||||||
|
// discipline is enforced by BurnLedger below + the per-leaf slot
|
||||||
|
// guard in fors_threshold_open.go.
|
||||||
|
//
|
||||||
|
// Constraint 1 draws the no-reconstruct line at the GLOBAL SEED, not at
|
||||||
|
// individual leaves: constraint 3 EXPECTS one-time leaf material to be
|
||||||
|
// revealed ("any revealed WOTS+/FORS one-time material..."). Opening a
|
||||||
|
// single one-time leaf secret is therefore permitted; assembling the
|
||||||
|
// global seed is not.
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrNoReconstructUnproven is the fail-closed sentinel returned by the
|
||||||
|
// Track-B signing entry and the POLARIS_MAX admission gate. It signals
|
||||||
|
// that no-reconstruct threshold SLH-DSA signing has not been proven for
|
||||||
|
// the full FIPS 205 signature, so the construction must not be admitted
|
||||||
|
// to a strict trustless cert tier.
|
||||||
|
var ErrNoReconstructUnproven = errors.New(
|
||||||
|
"magnetar/threshold: no-reconstruct threshold SLH-DSA signing is unproven for the full FIPS 205 signature " +
|
||||||
|
"(only the distributed FORS-leaf opening component is proven); Track B is NOT admitted to POLARIS_MAX")
|
||||||
|
|
||||||
|
// ErrLeafWidthViolation is returned (and is a fail-closed guard) when a
|
||||||
|
// no-reconstruct opening is asked to reconstruct a secret buffer wider
|
||||||
|
// than a single one-time leaf. It is the runtime expression of
|
||||||
|
// constraint 1: the open path may interpolate at most ONE leaf-width
|
||||||
|
// secret at a time, never the SeedSize-byte master.
|
||||||
|
var ErrLeafWidthViolation = errors.New(
|
||||||
|
"magnetar/threshold: no-reconstruct opening attempted to interpolate a buffer wider than one one-time leaf " +
|
||||||
|
"(this would be a global-seed/secret-key reconstruction --- refused)")
|
||||||
|
|
||||||
|
// NoReconstructProven reports whether the FULL no-reconstruct threshold
|
||||||
|
// SLH-DSA signing construction (a stock FIPS 205 signature with NO
|
||||||
|
// global-seed reconstruction anywhere in the path) has been proven and
|
||||||
|
// is therefore eligible for the POLARIS_MAX strict cert tier.
|
||||||
|
//
|
||||||
|
// It is HARD-WIRED false. Flipping it to true is a deliberate, reviewed
|
||||||
|
// act that MUST be accompanied by:
|
||||||
|
//
|
||||||
|
// - A no-reconstruct producer for the COMPLETE signature (FORS +
|
||||||
|
// hypertree), not just the FORS component.
|
||||||
|
// - A proof, under stock FIPS 205 Verify, that the producer's output
|
||||||
|
// is byte-accepted by an unmodified verifier.
|
||||||
|
// - A Byzantine-safe distributed BURN-STATE that guarantees WOTS+
|
||||||
|
// one-time and FORS few-time discipline across a live committee
|
||||||
|
// (the open obstruction --- see BurnLedger and the paper).
|
||||||
|
//
|
||||||
|
// Until then, every strict-tier consumer fails closed.
|
||||||
|
func NoReconstructProven() bool { return false }
|
||||||
|
|
||||||
|
// MagnetarTrack identifies which Magnetar threshold track a cert
|
||||||
|
// references. The admission gate dispatches on it.
|
||||||
|
type MagnetarTrack uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TrackUnspecified rejects every admission decision; the zero value
|
||||||
|
// is invalid by construction.
|
||||||
|
TrackUnspecified MagnetarTrack = 0
|
||||||
|
|
||||||
|
// TrackAQuorum is Magnetar-Quorum: P3Q rollup of independent
|
||||||
|
// validator SLH-DSA signatures. Trustless today; admitted to its
|
||||||
|
// own tiers by the Track-A surface, NOT by this gate.
|
||||||
|
TrackAQuorum MagnetarTrack = 1
|
||||||
|
|
||||||
|
// TrackBThreshold is Magnetar-Threshold: no-reconstruct threshold
|
||||||
|
// SLH-DSA. Research only. Refused by AdmitMagnetarThresholdToPolarisMax
|
||||||
|
// until NoReconstructProven().
|
||||||
|
TrackBThreshold MagnetarTrack = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the canonical track name.
|
||||||
|
func (t MagnetarTrack) String() string {
|
||||||
|
switch t {
|
||||||
|
case TrackAQuorum:
|
||||||
|
return "Magnetar-Quorum (Track A)"
|
||||||
|
case TrackBThreshold:
|
||||||
|
return "Magnetar-Threshold (Track B)"
|
||||||
|
default:
|
||||||
|
return "Magnetar-unspecified-track"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdmitMagnetarThresholdToPolarisMax is the SINGLE admission authority
|
||||||
|
// for whether a Magnetar-Threshold (Track B) lane may contribute to the
|
||||||
|
// POLARIS_MAX strict cert tier. It is the one-function gate (Hickey
|
||||||
|
// discipline: policy enforcement in ONE place, never braided into the
|
||||||
|
// crypto).
|
||||||
|
//
|
||||||
|
// It refuses Track B unconditionally until NoReconstructProven() is
|
||||||
|
// true. Track A is NOT this gate's concern --- a TrackAQuorum argument
|
||||||
|
// returns ErrWrongTrackForGate so callers cannot accidentally route the
|
||||||
|
// production lane through the research gate.
|
||||||
|
func AdmitMagnetarThresholdToPolarisMax(track MagnetarTrack) error {
|
||||||
|
switch track {
|
||||||
|
case TrackBThreshold:
|
||||||
|
if !NoReconstructProven() {
|
||||||
|
return ErrNoReconstructUnproven
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case TrackAQuorum:
|
||||||
|
return ErrWrongTrackForGate
|
||||||
|
default:
|
||||||
|
return ErrUnknownTrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrWrongTrackForGate is returned when Track A is passed to the
|
||||||
|
// Track-B POLARIS_MAX admission gate. Track A is admitted by its own
|
||||||
|
// (P3Q-rollup) surface; routing it here is a wiring bug.
|
||||||
|
var ErrWrongTrackForGate = errors.New(
|
||||||
|
"magnetar/threshold: Track A (Magnetar-Quorum) is not admitted by the Track-B gate; use the P3Q-rollup surface")
|
||||||
|
|
||||||
|
// ErrUnknownTrack is returned for an unspecified / unknown track.
|
||||||
|
var ErrUnknownTrack = errors.New("magnetar/threshold: unknown Magnetar track")
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// THE NO-RECONSTRUCT SIGNER INTERFACE
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// NoReconstructThresholdSigner is the Track-B signing contract. A
|
||||||
|
// conforming implementation produces ONE stock FIPS 205 SLH-DSA
|
||||||
|
// signature from a t-of-n committee such that NO party or aggregator
|
||||||
|
// ever holds the global seed / full secret key, even transiently.
|
||||||
|
//
|
||||||
|
// No such implementation exists for the COMPLETE signature. The
|
||||||
|
// fail-closed implementation below returns ErrNoReconstructUnproven.
|
||||||
|
// The proven COMPONENT (distributed FORS-leaf opening) implements only
|
||||||
|
// the FORS-bottom of this contract and is exposed separately in
|
||||||
|
// fors_threshold_open.go --- it is intentionally NOT a
|
||||||
|
// NoReconstructThresholdSigner, because it does not emit a complete
|
||||||
|
// signature.
|
||||||
|
type NoReconstructThresholdSigner interface {
|
||||||
|
// SignNoReconstruct produces a stock FIPS 205 signature on message
|
||||||
|
// under ctx from >= threshold partial openings, with no global-seed
|
||||||
|
// reconstruction. Returns ErrNoReconstructUnproven until the
|
||||||
|
// construction is proven.
|
||||||
|
SignNoReconstruct(message, ctx []byte, openings [][]byte) (*Signature, error)
|
||||||
|
|
||||||
|
// Mode reports the FIPS 205 parameter set this signer targets.
|
||||||
|
Mode() Mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// failClosedThresholdSigner is the only NoReconstructThresholdSigner
|
||||||
|
// Magnetar ships. Every signing call fails closed. It exists so that
|
||||||
|
// consumers can wire the Track-B interface end-to-end TODAY and observe
|
||||||
|
// the explicit refusal, rather than discovering at integration time
|
||||||
|
// that the lane silently degraded to a reconstructing combiner.
|
||||||
|
type failClosedThresholdSigner struct {
|
||||||
|
params *Params
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFailClosedThresholdSigner returns the fail-closed Track-B signer
|
||||||
|
// for the given mode. Its SignNoReconstruct always returns
|
||||||
|
// ErrNoReconstructUnproven.
|
||||||
|
func NewFailClosedThresholdSigner(params *Params) (NoReconstructThresholdSigner, error) {
|
||||||
|
if err := params.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &failClosedThresholdSigner{params: params}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignNoReconstruct fails closed.
|
||||||
|
func (s *failClosedThresholdSigner) SignNoReconstruct(_, _ []byte, _ [][]byte) (*Signature, error) {
|
||||||
|
return nil, ErrNoReconstructUnproven
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode reports the target parameter set.
|
||||||
|
func (s *failClosedThresholdSigner) Mode() Mode { return s.params.Mode }
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// CONSTRAINT-1 RUNTIME GUARD (leaf-width invariant)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// maxOpenWidth is the maximum secret-buffer width a no-reconstruct
|
||||||
|
// opening may interpolate at once: exactly ONE one-time leaf secret,
|
||||||
|
// which in FIPS 205 is n bytes (params.N). This is strictly less than
|
||||||
|
// SeedSize (= PrivateKeySize = 4n for every FIPS 205 set), so an
|
||||||
|
// interpolation at this width can never assemble the master seed.
|
||||||
|
func maxOpenWidth(params *Params) int { return params.N }
|
||||||
|
|
||||||
|
// assertLeafWidth is the fail-closed expression of constraint 1. Every
|
||||||
|
// no-reconstruct interpolation in fors_threshold_open.go passes its
|
||||||
|
// target width through this guard. A width > one leaf (i.e. anything
|
||||||
|
// approaching SeedSize) is refused with ErrLeafWidthViolation: the open
|
||||||
|
// path is structurally incapable of reconstructing the global seed.
|
||||||
|
//
|
||||||
|
// This is a CONSTRUCTIVE guard, not merely a test assertion --- it sits
|
||||||
|
// on the live open path so a future refactor that tried to widen an
|
||||||
|
// interpolation to seed size would fault immediately.
|
||||||
|
func assertLeafWidth(params *Params, width int) error {
|
||||||
|
if width <= 0 || width > maxOpenWidth(params) {
|
||||||
|
return ErrLeafWidthViolation
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
//
|
||||||
|
// THE ONE-TIME / FEW-TIME BURN LEDGER (constraint 3; the crux)
|
||||||
|
//
|
||||||
|
// =====================================================================
|
||||||
|
//
|
||||||
|
// The deepest obstruction to no-reconstruct threshold SLH-DSA is NOT
|
||||||
|
// the no-reconstruct property of a single opening --- the FORS component
|
||||||
|
// achieves that. It is GLOBAL one-time/few-time discipline across a
|
||||||
|
// live, possibly-Byzantine committee:
|
||||||
|
//
|
||||||
|
// - WOTS+ keypairs are STRICTLY ONE-TIME. Signing two distinct
|
||||||
|
// message digests with one WOTS+ keypair reveals chain values at
|
||||||
|
// two different chain lengths; the shorter one lets an adversary
|
||||||
|
// extend to forge the other --- full WOTS+ key recovery, hence a
|
||||||
|
// hypertree forgery. A threshold protocol MUST guarantee that for
|
||||||
|
// every (layer, tree, leaf) WOTS+ address, the committee opens chain
|
||||||
|
// material for AT MOST ONE message digest, forever.
|
||||||
|
//
|
||||||
|
// - FORS keypairs are FEW-TIME. Opening leaf secrets of one FORS
|
||||||
|
// keypair for multiple messages degrades security as roughly
|
||||||
|
// (reuse)^k; the few-time margin must not be exceeded.
|
||||||
|
//
|
||||||
|
// In stateless SLH-DSA the keypair selection (idxTree, idxLeaf) is a
|
||||||
|
// pseudo-random function of R = PRF_msg(SK.prf, PK.seed, M'), and the
|
||||||
|
// huge hypertree (2^63..2^68 leaves) makes collisions negligible for a
|
||||||
|
// single signer. In a THRESHOLD setting SK.prf is itself shared, so R
|
||||||
|
// must be produced by the committee, and the committee must DETECT and
|
||||||
|
// REFUSE any second opening of an already-burned one-time address.
|
||||||
|
//
|
||||||
|
// BurnLedger is the durable, fault-tolerant state that enforces this.
|
||||||
|
// It is the "stateful subprotocol under a stateless-looking cert
|
||||||
|
// boundary" (cf. NIST Haystack, threshold/distributed stateful HBS).
|
||||||
|
// The in-memory ledger below is the LOCAL-state reference; a production
|
||||||
|
// deployment lifts it to Byzantine-fault-tolerant replicated state
|
||||||
|
// (consensus-anchored burn proofs). The Byzantine-safe lift is the open
|
||||||
|
// obstruction --- see the paper. NoReconstructProven() cannot return
|
||||||
|
// true until that lift is proven.
|
||||||
|
type BurnLedger struct {
|
||||||
|
// burned maps a one-time address key to the message digest it was
|
||||||
|
// burned for. A second open at the same address under a different
|
||||||
|
// digest is an equivocation: refused, and the (address, two digests)
|
||||||
|
// tuple is slashable evidence.
|
||||||
|
burned map[OneTimeAddr][32]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// OneTimeAddr canonically identifies a one-time/few-time signing slot in
|
||||||
|
// the SLH-DSA forest: the FIPS 205 hypertree coordinates plus the
|
||||||
|
// component kind. It is the burn-ledger key. Values, not places: the
|
||||||
|
// address is defined by what it IS (a coordinate in the forest), not by
|
||||||
|
// who holds it.
|
||||||
|
type OneTimeAddr struct {
|
||||||
|
// Kind distinguishes WOTS+ (strictly one-time) from FORS (few-time)
|
||||||
|
// addresses so the ledger can apply the correct reuse budget.
|
||||||
|
Kind OneTimeKind
|
||||||
|
// Layer is the hypertree layer (0 = bottom). FORS lives below layer 0.
|
||||||
|
Layer uint32
|
||||||
|
// Tree is the XMSS tree index within the layer (low 32 bits; the
|
||||||
|
// full FIPS 205 tree address is 3 words --- the reference ledger keys
|
||||||
|
// on the low word, sufficient for the bounded test forests; a
|
||||||
|
// production ledger keys on the full 3-word address).
|
||||||
|
Tree uint32
|
||||||
|
// Leaf is the keypair (XMSS leaf / FORS keypair) index within the
|
||||||
|
// tree.
|
||||||
|
Leaf uint32
|
||||||
|
// ForsTree is the FORS sub-tree index in [0, k) for FORS addresses;
|
||||||
|
// zero for WOTS+ addresses.
|
||||||
|
ForsTree uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// OneTimeKind classifies the reuse budget of a one-time address.
|
||||||
|
type OneTimeKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// KindWotsOneTime is a WOTS+ keypair: STRICTLY one message, ever.
|
||||||
|
KindWotsOneTime OneTimeKind = 1
|
||||||
|
// KindForsFewTime is a FORS keypair: few-time, budget enforced by
|
||||||
|
// the few-time parameters (k, a). The reference ledger applies a
|
||||||
|
// strict one-message default; a production ledger may widen to the
|
||||||
|
// proven few-time budget.
|
||||||
|
KindForsFewTime OneTimeKind = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewBurnLedger returns an empty local burn ledger.
|
||||||
|
func NewBurnLedger() *BurnLedger {
|
||||||
|
return &BurnLedger{burned: make(map[OneTimeAddr][32]byte)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Burn records that addr is consumed for digest. It returns
|
||||||
|
// ErrOneTimeReuse (carrying the conflicting prior digest via
|
||||||
|
// BurnConflict) if addr was already burned for a DIFFERENT digest.
|
||||||
|
// Re-burning the SAME (addr, digest) is idempotent (a benign retry).
|
||||||
|
//
|
||||||
|
// This is the fail-closed enforcement of constraint 3: the open path
|
||||||
|
// MUST call Burn before releasing one-time material, and abort on
|
||||||
|
// conflict. A production committee replaces the local map with
|
||||||
|
// consensus-anchored burn proofs so the guarantee survives crashes,
|
||||||
|
// restarts, and Byzantine equivocation.
|
||||||
|
func (l *BurnLedger) Burn(addr OneTimeAddr, digest [32]byte) error {
|
||||||
|
if prior, ok := l.burned[addr]; ok {
|
||||||
|
if prior != digest {
|
||||||
|
return &BurnConflict{Addr: addr, Prior: prior, New: digest}
|
||||||
|
}
|
||||||
|
return nil // idempotent retry
|
||||||
|
}
|
||||||
|
l.burned[addr] = digest
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBurned reports whether addr has been consumed.
|
||||||
|
func (l *BurnLedger) IsBurned(addr OneTimeAddr) bool {
|
||||||
|
_, ok := l.burned[addr]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// BurnConflict is the slashable evidence of a one-time-address reuse
|
||||||
|
// attempt: the same one-time slot opened for two distinct messages.
|
||||||
|
type BurnConflict struct {
|
||||||
|
Addr OneTimeAddr
|
||||||
|
Prior [32]byte
|
||||||
|
New [32]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements error.
|
||||||
|
func (e *BurnConflict) Error() string {
|
||||||
|
return "magnetar/threshold: one-time address reuse --- a WOTS+/FORS slot was opened for two distinct message digests (slashable)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrOneTimeReuse is a sentinel for errors.Is matching against a
|
||||||
|
// BurnConflict.
|
||||||
|
var ErrOneTimeReuse = errors.New("magnetar/threshold: one-time address reuse")
|
||||||
|
|
||||||
|
// Is lets errors.Is(err, ErrOneTimeReuse) match a *BurnConflict.
|
||||||
|
func (e *BurnConflict) Is(target error) bool { return target == ErrOneTimeReuse }
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||||
|
// See the file LICENSE for licensing terms.
|
||||||
|
|
||||||
|
package magnetar
|
||||||
|
|
||||||
|
// threshold_noreconstruct_test.go --- proves the Track-B skeleton fails
|
||||||
|
// closed (no silent admission to POLARIS_MAX) and proves the structural
|
||||||
|
// no-reconstruct invariant: the SIGN/combiner path (OpenForsThreshold)
|
||||||
|
// never invokes a seed-reconstruction primitive, and the leaf-width
|
||||||
|
// guard refuses any interpolation wider than one one-time leaf.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNoReconstruct_FailClosed proves the Track-B signer + admission gate
|
||||||
|
// fail closed until NoReconstructProven().
|
||||||
|
func TestNoReconstruct_FailClosed(t *testing.T) {
|
||||||
|
if NoReconstructProven() {
|
||||||
|
t.Fatalf("NoReconstructProven() must be hard-wired false until the full construction is proven")
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := NewFailClosedThresholdSigner(MustParamsFor(ModeM192s))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFailClosedThresholdSigner: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := signer.SignNoReconstruct([]byte("m"), []byte("ctx"), nil); !errors.Is(err, ErrNoReconstructUnproven) {
|
||||||
|
t.Fatalf("SignNoReconstruct must fail closed with ErrNoReconstructUnproven, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POLARIS_MAX admission gate refuses Track B.
|
||||||
|
if err := AdmitMagnetarThresholdToPolarisMax(TrackBThreshold); !errors.Is(err, ErrNoReconstructUnproven) {
|
||||||
|
t.Fatalf("Track B must be refused admission to POLARIS_MAX, got %v", err)
|
||||||
|
}
|
||||||
|
// Track A is not this gate's concern.
|
||||||
|
if err := AdmitMagnetarThresholdToPolarisMax(TrackAQuorum); !errors.Is(err, ErrWrongTrackForGate) {
|
||||||
|
t.Fatalf("Track A must return ErrWrongTrackForGate, got %v", err)
|
||||||
|
}
|
||||||
|
if err := AdmitMagnetarThresholdToPolarisMax(TrackUnspecified); !errors.Is(err, ErrUnknownTrack) {
|
||||||
|
t.Fatalf("unspecified track must return ErrUnknownTrack, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoReconstruct_LeafWidthGuard proves constraint 1's runtime guard:
|
||||||
|
// an interpolation at one-leaf width is permitted; anything approaching
|
||||||
|
// SeedSize is refused.
|
||||||
|
func TestNoReconstruct_LeafWidthGuard(t *testing.T) {
|
||||||
|
params := MustParamsFor(ModeM192s)
|
||||||
|
if params.N >= params.SeedSize {
|
||||||
|
t.Fatalf("invariant broken: a leaf (n=%d) must be strictly narrower than the seed (%d)", params.N, params.SeedSize)
|
||||||
|
}
|
||||||
|
if err := assertLeafWidth(params, params.N); err != nil {
|
||||||
|
t.Fatalf("one-leaf width must be permitted, got %v", err)
|
||||||
|
}
|
||||||
|
if err := assertLeafWidth(params, params.SeedSize); !errors.Is(err, ErrLeafWidthViolation) {
|
||||||
|
t.Fatalf("seed-width interpolation must be refused, got %v", err)
|
||||||
|
}
|
||||||
|
if err := assertLeafWidth(params, params.N+1); !errors.Is(err, ErrLeafWidthViolation) {
|
||||||
|
t.Fatalf("width > one leaf must be refused, got %v", err)
|
||||||
|
}
|
||||||
|
if err := assertLeafWidth(params, 0); !errors.Is(err, ErrLeafWidthViolation) {
|
||||||
|
t.Fatalf("zero width must be refused, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forbiddenNoReconstructCalls are the seed-reconstruction / secret-side
|
||||||
|
// primitives that the no-reconstruct SIGN path must NEVER invoke. If
|
||||||
|
// OpenForsThreshold called any of these it would be reconstructing the
|
||||||
|
// global seed (KeyFromSeed / shakeIntoCat on the seed), running the
|
||||||
|
// secret-side signer (forsSign / slhSignAtom / assembleSignatureBytes),
|
||||||
|
// or dealing fresh shares (thbsseDealRandom*).
|
||||||
|
var forbiddenNoReconstructCalls = map[string]struct{}{
|
||||||
|
"forsSign": {},
|
||||||
|
"slhSignAtom": {},
|
||||||
|
"assembleSignatureBytes": {},
|
||||||
|
"KeyFromSeed": {},
|
||||||
|
"GenerateKey": {},
|
||||||
|
"shakeIntoCat": {},
|
||||||
|
"makePRFClosure": {},
|
||||||
|
"makePRFMsgClosure": {},
|
||||||
|
"thbsseDealRandom": {},
|
||||||
|
"thbsseDealRandomGF": {},
|
||||||
|
"deriveDKGPublicKey": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// requiredNoReconstructCalls are the no-reconstruct primitives that
|
||||||
|
// OpenForsThreshold MUST invoke: the leaf-width Lagrange, the
|
||||||
|
// constraint-1 guard, and the constraint-3 burn.
|
||||||
|
var requiredNoReconstructCalls = map[string]struct{}{
|
||||||
|
"thbsseReconstructGF": {}, // leaf-width interpolation only
|
||||||
|
"assertLeafWidth": {}, // constraint 1 guard
|
||||||
|
"Burn": {}, // constraint 3 burn
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectCallNames returns the set of called function/method names within
|
||||||
|
// a function body (ast.Ident callees and ast.SelectorExpr selector names).
|
||||||
|
func collectCallNames(fn *ast.FuncDecl) map[string]struct{} {
|
||||||
|
names := make(map[string]struct{})
|
||||||
|
ast.Inspect(fn, func(node ast.Node) bool {
|
||||||
|
call, ok := node.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch f := call.Fun.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
names[f.Name] = struct{}{}
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
names[f.Sel.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// findFunc parses a source file in the current package directory and
|
||||||
|
// returns the named top-level function declaration.
|
||||||
|
func findFunc(t *testing.T, file, name string) *ast.FuncDecl {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, file, nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", file, err)
|
||||||
|
}
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv == nil && fn.Name.Name == name {
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("function %s not found in %s", name, file)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoReconstruct_SourceGate_NoSeedReconstruction is the STRUCTURAL
|
||||||
|
// gate: it parses the no-reconstruct SIGN path (OpenForsThreshold) and
|
||||||
|
// asserts it never calls a seed-reconstruction / secret-side primitive,
|
||||||
|
// and DOES call the leaf-width interpolation + constraint guards.
|
||||||
|
//
|
||||||
|
// This is the structural counterpart of the runtime leaf-width guard:
|
||||||
|
// together they prove the SIGN path is incapable of forming the global
|
||||||
|
// seed. Contrast: assembleSignatureBytes (the existing reveal-and-
|
||||||
|
// aggregate Combine path) DOES call shakeIntoCat on a full SeedSize
|
||||||
|
// buffer --- this test asserts that contrast is real.
|
||||||
|
func TestNoReconstruct_SourceGate_NoSeedReconstruction(t *testing.T) {
|
||||||
|
open := findFunc(t, "fors_threshold_open.go", "OpenForsThreshold")
|
||||||
|
calls := collectCallNames(open)
|
||||||
|
|
||||||
|
for forbidden := range forbiddenNoReconstructCalls {
|
||||||
|
if _, found := calls[forbidden]; found {
|
||||||
|
t.Fatalf("STRUCTURAL VIOLATION: OpenForsThreshold calls seed-reconstruction primitive %q --- "+
|
||||||
|
"the no-reconstruct SIGN path must never invoke it", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for required := range requiredNoReconstructCalls {
|
||||||
|
if _, found := calls[required]; !found {
|
||||||
|
t.Fatalf("OpenForsThreshold must call no-reconstruct primitive %q (leaf-width Lagrange / constraint guards)", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the contrast is real: the EXISTING reconstruct path
|
||||||
|
// (assembleSignatureBytes) DOES expand a full buffer via shakeIntoCat.
|
||||||
|
// If this ever stops being true the contrast claim is stale.
|
||||||
|
assemble := findFunc(t, "thbsse_assemble.go", "assembleSignatureBytes")
|
||||||
|
asmCalls := collectCallNames(assemble)
|
||||||
|
if _, found := asmCalls["shakeIntoCat"]; !found {
|
||||||
|
t.Fatalf("contrast stale: assembleSignatureBytes no longer expands a full buffer via shakeIntoCat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBurnLedger_IdempotentAndConflict unit-tests the burn ledger.
|
||||||
|
func TestBurnLedger_IdempotentAndConflict(t *testing.T) {
|
||||||
|
l := NewBurnLedger()
|
||||||
|
addr := OneTimeAddr{Kind: KindWotsOneTime, Layer: 0, Tree: 1, Leaf: 2}
|
||||||
|
var d1, d2 [32]byte
|
||||||
|
d1[0], d2[0] = 1, 2
|
||||||
|
|
||||||
|
if err := l.Burn(addr, d1); err != nil {
|
||||||
|
t.Fatalf("first burn: %v", err)
|
||||||
|
}
|
||||||
|
if err := l.Burn(addr, d1); err != nil {
|
||||||
|
t.Fatalf("idempotent re-burn under same digest must succeed: %v", err)
|
||||||
|
}
|
||||||
|
if !l.IsBurned(addr) {
|
||||||
|
t.Fatalf("addr must be burned")
|
||||||
|
}
|
||||||
|
err := l.Burn(addr, d2)
|
||||||
|
if !errors.Is(err, ErrOneTimeReuse) {
|
||||||
|
t.Fatalf("re-burn under different digest must conflict, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user