Files
dealer/legacy/large_threshold.go
zeekay 0712bfb686 Archive: legacy trusted-dealer / reconstruct path (superseded, not used)
Snapshot of the legacy_trusted_dealer threshold-signing path removed from
luxfi/pulsar at v0.7.0: the LargeCombine reconstruct-at-combiner (H-1 footgun)
and the DealAlgShares trusted-dealer keygen. Superseded by dealerless Mithril RSS
keygen + no-reconstruct signing (luxfi/pulsar). Preserved for reference only —
package pulsar internals, not a standalone buildable module. See README.md.
2026-06-28 18:43:41 -07:00

347 lines
10 KiB
Go

//go:build legacy_trusted_dealer
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// LEGACY (quarantined): GF(q) SEED-share committee path that RECONSTRUCTS
// the master key at sign time (KeyFromSeed in LargeCombine — the H-1
// footgun). NOT in the default production build. The production committee
// threshold-sign path is the no-reconstruct AlgShare/AggregateBCC
// RoundSigner (distributed_bcc.go), which never forms s1/seed/sk at the
// combiner. Build with `-tags legacy_trusted_dealer` only for the
// explicitly opt-in audit-attestation / bootstrap deployment that accepts
// centralized reconstruction.
package pulsar
// large_threshold.go -- GF(q) two-round threshold signing. The
// parallel of threshold.go's ThresholdSigner / Combine for the
// wide-committee regime, with the same MAC + commit-and-reveal
// shape and the same cSHAKE256 mix to derive the master seed.
//
// Same caveat as large_dkg.go: the canonical Lux deployment runs
// the small-committee (GF(257)) path at (T, N) = (2, 3) per
// sortitioned group; this Large* path is the alternative big-
// committee deployment for permissioned consortium / audit-
// attestation scenarios where a single large committee is desired.
//
// CR-7 closure (2026-05-18): the per-pair MAC keys are now ephemeral
// per-session session keys established via authenticated ML-KEM-768
// key agreement (see identity.go), the same construction the small-
// committee path uses. The previous legacy derivation from public
// inputs (NodeID pair + group public key) was forgeable by any
// network observer; that derivation is gone from the package.
import (
"crypto/rand"
"io"
)
// LargeThresholdSigner holds one party's state for one GF(q) threshold
// sign ceremony.
type LargeThresholdSigner struct {
Params *Params
NodeID NodeID
SecretShare *LargeKeyShare
SessionID [16]byte
Attempt uint32
Quorum []NodeID
Message []byte
// MACKeys is the per-pair MAC key set for this session. Each key
// is an ephemeral session key established via authenticated
// ML-KEM-768 exchange between this party and its peer
// (see EstablishSession in identity.go).
MACKeys map[NodeID][32]byte
rng io.Reader
myMask [shareWireSizeQ]byte
myMaskedShare [shareWireSizeQ]byte
myCommit [32]byte
receivedR1 []*LargeRound1Message
}
// NewLargeThresholdSigner constructs a new GF(q) threshold signer.
// quorum is canonicalised (byte-ascending NodeID) and must include
// myShare.NodeID; quorum size is capped at TargetCommitteeSize.
//
// sessionKeys carries this party's per-peer ephemeral session key for
// every other quorum member. Each session key must be the byte-equal
// output of EstablishSession run between this party and the peer
// (typically: caller drives Round 0 of identity.go's KEM exchange
// before constructing the LargeThresholdSigner). The map MUST contain
// an entry for every peer in quorum except myShare.NodeID itself;
// missing entries return ErrSessionKeyMissing.
func NewLargeThresholdSigner(
params *Params,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
myShare *LargeKeyShare,
sessionKeys map[NodeID][32]byte,
message []byte,
rng io.Reader,
) (*LargeThresholdSigner, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if myShare == nil {
return nil, ErrNilKey
}
if myShare.Mode != params.Mode {
return nil, ErrModeMismatch
}
if len(quorum) == 0 {
return nil, ErrEmptyQuorum
}
if len(quorum) > TargetCommitteeSize {
return nil, ErrCommitteeAboveCap
}
found := false
for _, q := range quorum {
if q == myShare.NodeID {
found = true
break
}
}
if !found {
return nil, ErrNotInQuorum
}
if rng == nil {
rng = rand.Reader
}
macKeys := make(map[NodeID][32]byte, len(quorum)-1)
for _, peer := range quorum {
if peer == myShare.NodeID {
continue
}
key, ok := sessionKeys[peer]
if !ok {
return nil, ErrSessionKeyMissing
}
macKeys[peer] = key
}
return &LargeThresholdSigner{
Params: params,
NodeID: myShare.NodeID,
SecretShare: myShare,
SessionID: sessionID,
Attempt: attempt,
Quorum: quorum,
Message: append([]byte{}, message...),
MACKeys: macKeys,
rng: rng,
}, nil
}
// Round1 samples the per-round mask, computes the commit, and emits
// the Round-1 broadcast.
func (s *LargeThresholdSigner) Round1(message []byte) (*LargeRound1Message, error) {
if _, err := io.ReadFull(s.rng, s.myMask[:]); err != nil {
return nil, ErrShortRand
}
for i := 0; i < shareWireSizeQ; i++ {
s.myMaskedShare[i] = s.SecretShare.Share[i] ^ s.myMask[i]
}
tau := transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, s.NodeID, s.SecretShare.Pub, s.Message)
commitInput := append(append([]byte{}, s.myMask[:]...), s.myMaskedShare[:]...)
commitInput = append(commitInput, tau...)
s.myCommit = transcriptHash32(tagSignR1, commitInput)
macs := make(map[NodeID][32]byte, len(s.Quorum)-1)
for _, peer := range s.Quorum {
if peer == s.NodeID {
continue
}
key := s.MACKeys[peer]
macInput := append(append([]byte{}, s.myCommit[:]...), tau...)
mac := kmac256(key[:], macInput, 32, tagSignR1MAC)
var macArr [32]byte
copy(macArr[:], mac)
macs[peer] = macArr
}
return &LargeRound1Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
Commit: s.myCommit,
MACs: macs,
}, nil
}
// Round2 verifies MACs and emits the (mask, masked_share) reveal.
func (s *LargeThresholdSigner) Round2(round1Msgs []*LargeRound1Message) (*LargeRound2Message, *AbortEvidence, error) {
if len(round1Msgs) < 1 {
return nil, nil, ErrEmptyQuorum
}
for _, m := range round1Msgs {
if m.SessionID != s.SessionID {
return nil, nil, ErrSessionMismatch
}
if m.Attempt != s.Attempt {
return nil, nil, ErrAttemptMismatch
}
if m.NodeID == s.NodeID {
continue
}
key := s.MACKeys[m.NodeID]
tau := transcriptTau1Bytes(s.SessionID, s.Attempt, s.Quorum, m.NodeID, s.SecretShare.Pub, s.Message)
macInput := append(append([]byte{}, m.Commit[:]...), tau...)
expectedMAC := kmac256(key[:], macInput, 32, tagSignR1MAC)
gotMAC, ok := m.MACs[s.NodeID]
if !ok {
return nil, &AbortEvidence{
Kind: ComplaintMACFailure,
Accuser: s.NodeID,
Accused: m.NodeID,
}, ErrRound1MACBad
}
if !ctEqualSlice(expectedMAC, gotMAC[:]) {
return nil, &AbortEvidence{
Kind: ComplaintMACFailure,
Accuser: s.NodeID,
Accused: m.NodeID,
Evidence: append(append([]byte{}, expectedMAC...), gotMAC[:]...),
}, ErrRound1MACBad
}
}
s.receivedR1 = round1Msgs
revealed := make([]byte, 0, 2*shareWireSizeQ)
revealed = append(revealed, s.myMask[:]...)
revealed = append(revealed, s.myMaskedShare[:]...)
return &LargeRound2Message{
NodeID: s.NodeID,
SessionID: s.SessionID,
Attempt: s.Attempt,
W1: nil,
PartialSig: revealed,
}, nil, nil
}
// LargeCombine reconstructs the master seed from a quorum of GF(q)
// Round-2 reveals and emits a single FIPS 204 ML-DSA signature.
func LargeCombine(params *Params, groupPubkey *PublicKey, message []byte, ctx []byte, randomized bool, sessionID [16]byte, attempt uint32, quorum []NodeID, threshold int, round1 []*LargeRound1Message, round2 []*LargeRound2Message, allShares []*LargeKeyShare) (*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
}
r1ByID := make(map[NodeID]*LargeRound1Message, len(round1))
for _, m := range round1 {
if m.SessionID != sessionID || m.Attempt != attempt {
return nil, ErrSessionMismatch
}
r1ByID[m.NodeID] = m
}
revealedShares := make(map[NodeID][shareWireSizeQ]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*shareWireSizeQ {
return nil, ErrRound2CommitBad
}
var mask [shareWireSizeQ]byte
var masked [shareWireSizeQ]byte
copy(mask[:], r2.PartialSig[:shareWireSizeQ])
copy(masked[:], r2.PartialSig[shareWireSizeQ:])
tau := transcriptTau1Bytes(sessionID, attempt, quorum, r2.NodeID, groupPubkey, message)
commitInput := append(append([]byte{}, mask[:]...), masked[:]...)
commitInput = append(commitInput, tau...)
recomputed := transcriptHash32(tagSignR1, commitInput)
if !ctEqual32(recomputed, r1.Commit) {
return nil, ErrRound2CommitBad
}
var share [shareWireSizeQ]byte
for i := 0; i < shareWireSizeQ; i++ {
share[i] = masked[i] ^ mask[i]
}
revealedShares[r2.NodeID] = share
}
if len(revealedShares) < threshold {
return nil, ErrInsufficientQuor
}
keyShareByID := make(map[NodeID]*LargeKeyShare, len(allShares))
for _, ks := range allShares {
keyShareByID[ks.NodeID] = ks
}
shares := make([]shamirShareQ, 0, threshold)
for id, sBytes := range revealedShares {
ks, ok := keyShareByID[id]
if !ok {
return nil, ErrNotInQuorum
}
var buf [shareWireSizeQ]byte
copy(buf[:], sBytes[:])
shares = append(shares, shareFromBytesQ(ks.EvalPoint, buf))
if len(shares) == threshold {
break
}
}
byteSum, err := shamirReconstructGFQ(shares)
if err != nil {
return nil, err
}
committeeRoot := committeeRootFromLargeShares(allShares)
byteSumBytes := make([]byte, SeedSize*4)
for b := 0; b < SeedSize; b++ {
byteSumBytes[4*b] = byte(byteSum[b] >> 24)
byteSumBytes[4*b+1] = byte(byteSum[b] >> 16)
byteSumBytes[4*b+2] = byte(byteSum[b] >> 8)
byteSumBytes[4*b+3] = byte(byteSum[b])
}
mixInput := append(append([]byte{}, byteSumBytes...), committeeRoot[:]...)
var masterSeed [SeedSize]byte
copy(masterSeed[:], cshake256(mixInput, SeedSize, tagSeedShare))
sk, err := KeyFromSeed(params, masterSeed)
if err != nil {
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, err
}
if !sk.Pub.Equal(groupPubkey) {
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, ErrPubkeyMismatch
}
sigBytes, err := mldsaSign(params.Mode, sk.Bytes, message, ctx, randomized, rand.Reader)
if err != nil {
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return nil, err
}
zeroizePrivateKey(sk)
zeroizeSeed(&masterSeed)
zeroizeBytes(byteSumBytes)
zeroizeBytes(mixInput)
return &Signature{Mode: params.Mode, Bytes: sigBytes}, nil
}