wip(gate-b): port thresholdd pulsar+corona dispatchers to DEALERLESS committee DKG

pulsar.go: rewrite onto pulsar v1.2.0 large_* GF(q) committee-DKG API
(NewLargeDKGSession 3-round dealerless keygen + NewLargeThresholdSigner
Round1/2 + LargeCombine). Removes the v0.3 trusted-dealer
DealAlgebraicV03Shares path entirely. Sign/Sign_Ctx collapse into one
ctx-aware largeSign helper. Strict-PQ gate KEPT with corrected reasoning
(LargeCombine is reconstruct-at-combine, weaker than no-reconstruct
TALUS; gate guards dev-tooling dispatcher from feeding strict-PQ chains).

corona.go: dealerless keyera.Bootstrap (Pedersen dkg2) rewire.

Package compiles clean (GOWORK=off go build ./pkg/thresholdd/).
This commit is contained in:
zeekay
2026-06-27 18:45:34 -07:00
parent cc3fb58d23
commit e0d4bf8bef
2 changed files with 451 additions and 310 deletions
+49 -20
View File
@@ -4,10 +4,12 @@ package thresholdd
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"sync"
"github.com/luxfi/corona/keyera"
coronaThreshold "github.com/luxfi/corona/threshold"
)
@@ -22,13 +24,13 @@ import (
//
// Trust model on keygen:
//
// - The dispatcher runs the trusted-dealer GenerateKeys path
// in-process (this is the dispatcher contract for off-chain test
// harnesses, NOT the on-chain production path). The Pedersen-DKG
// no-trusted-dealer path lives at luxfi/corona/keyera.Bootstrap and
// is what consensus drives at chain genesis. The dispatcher exists
// for off-chain test harnesses, MPC bus integration tests, and
// SDK-driven dev tooling — not for chain-genesis ceremonies.
// - DEALERLESS. The dispatcher runs the same no-trusted-dealer
// Pedersen DKG (luxfi/corona/keyera.Bootstrap → dkg2 over R_q +
// noise flooding) that consensus drives at chain genesis. No single
// party ever holds the master secret s at any point in the ceremony
// (PUBLIC-BFT-SAFE). There is NO trusted-dealer path in this
// dispatcher — the off-chain harness and the on-chain ceremony use
// one and the same dealerless keygen.
//
// Trust model on sign:
//
@@ -70,9 +72,14 @@ func newCoronaScheme() *coronaScheme {
return &coronaScheme{sessions: make(map[string]*coronaSession)}
}
// Keygen runs corona.threshold.GenerateKeysTrustedDealer for t-of-n,
// publishes canonical GroupKey wire bytes as PublicKey, and returns one
// hex blob per party in Shares.
// Keygen runs the DEALERLESS Pedersen DKG (corona keyera.Bootstrap,
// dkg2 over R_q + noise flooding) for t-of-n, publishes canonical
// GroupKey wire bytes as PublicKey, and returns one hex blob per party
// in Shares.
//
// NO trusted dealer: every validator runs dkg2.Round1 independently and
// no single party ever holds the master secret s during the ceremony.
// This is the identical path consensus drives at chain genesis.
//
// The Shares slice contains the per-party KeyShare INDICES (decimal),
// not the raw secret material. The dispatcher retains the actual
@@ -84,20 +91,42 @@ func (s *coronaScheme) Keygen(p keygenParams) (keygenResult, error) {
if err := validateKeygenParams(p); err != nil {
return keygenResult{}, err
}
// corona requires t < n strictly (the kernel enforces this in
// GenerateKeysTrustedDealer: see threshold.go:128).
// corona dkg2 requires t < n strictly and n >= 2.
if p.Threshold >= p.Participants {
return keygenResult{}, fmt.Errorf("corona keygen: threshold must be < participants (corona kernel constraint)")
return keygenResult{}, fmt.Errorf("corona keygen: threshold must be < participants (corona dkg2 constraint)")
}
// corona v0.8.0 renamed GenerateKeys -> GenerateKeysTrustedDealer to
// make the dealer trust model explicit and greppable (one party
// materializes the whole secret). The dispatcher is the off-chain
// test-harness / dev-tooling surface, so the trusted-dealer fast path
// is the intended call here; chain genesis uses keyera.Bootstrap.
shares, gk, err := coronaThreshold.GenerateKeysTrustedDealer(p.Threshold, p.Participants, rand.Reader)
// Canonical 1-indexed validator set. keyera.Bootstrap returns the
// share map keyed by these same strings, so we re-order by them.
validators := make([]string, p.Participants)
for i := range validators {
validators[i] = fmt.Sprintf("%d", i+1)
}
// Fresh group id so concurrent in-process keygens cannot alias; era
// id is the genesis era (1) for this fresh group.
var gidBuf [8]byte
if _, err := rand.Read(gidBuf[:]); err != nil {
return keygenResult{}, fmt.Errorf("corona keygen: group-id entropy: %w", err)
}
groupID := keyera.CoronaGroupID(binary.BigEndian.Uint64(gidBuf[:]))
// Dealerless Pedersen DKG. No master secret is ever materialized.
era, _, err := keyera.Bootstrap(p.Threshold, validators, groupID, keyera.CoronaKeyEraID(1), rand.Reader)
if err != nil {
return keygenResult{}, fmt.Errorf("corona keygen: %w", err)
return keygenResult{}, fmt.Errorf("corona keygen (dealerless dkg2): %w", err)
}
gk := era.GroupKey
// Re-order the dealerless share map into the canonical 1..n slice the
// in-process Sign path indexes by position.
shares := make([]*coronaThreshold.KeyShare, p.Participants)
for i, v := range validators {
sh, ok := era.State.Shares[v]
if !ok {
return keygenResult{}, fmt.Errorf("corona keygen: dkg2 produced no share for validator %q", v)
}
shares[i] = sh
}
gkBytes, err := gk.MarshalBinary()
+402 -290
View File
@@ -2,11 +2,13 @@
package thresholdd
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"sort"
"sync"
pulsar "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
@@ -15,125 +17,144 @@ import (
// pulsarScheme wires luxfi/pulsar (Module-LWE FIPS 204 ML-DSA-65
// post-quantum threshold signatures) into the dispatcher's scheme surface.
//
// Wire-format contract (closed 2026-05-31): pulsar now publishes
// canonical MarshalBinary / UnmarshalBinary on Signature and
// PublicKey (the wire-form group public key, in a PULG frame) plus
// a stateless VerifyBytes(gkBytes, msg, sigBytes) helper. Every
// output of this scheme is bytes that any independent verifier
// holding the published PULG-framed group public key can validate.
// The headline cryptographic claim — that the Pulsar threshold
// signature is bit-identical to a single-party FIPS 204 ML-DSA
// signature on the same (message, group public key) — is pinned
// upstream by TestPulsar_Wire_FIPS204Verifiable, TestAlgebraic_ByteValid,
// and TestAlgebraic_FullCycle_n5_t3.
// Wire-format contract: pulsar publishes canonical MarshalBinary /
// UnmarshalBinary on Signature and PublicKey (the wire-form group public
// key, in a PULG frame) plus a stateless VerifyBytes(gkBytes, msg,
// sigBytes) helper. Every output of this scheme is bytes that any
// independent verifier holding the published PULG-framed group public
// key can validate. The headline cryptographic claim — that the Pulsar
// threshold signature is bit-identical to a single-party FIPS 204
// ML-DSA-65 signature on the same (message, group public key) — is
// pinned upstream by TestPrecompile_E2E_LargeCombine_FIPS204_VerifyCtx
// and TestLarge_E2E_DKG_ThresholdSign_Verify.
//
// Trust model on keygen:
// ----------------------------------------------------------------------
// Trust model on KEYGEN — DEALERLESS committee DKG (pulsar v1.2.0).
// ----------------------------------------------------------------------
//
// - The dispatcher runs the trusted-dealer
// pulsar.DealAlgebraicV03Shares path in-process. The v0.3
// algebraic-aggregate sign path then never reconstructs the
// master sk anywhere — parties hold polynomial-vector Shamir
// shares of (s_1, s_2, t_0) over GF(q) and the aggregator
// combines (z, c·s_2, c·t_0) under Lagrange-linearity
// (TestAlgebraic_NoSkAccess pins this AST-structurally). This
// matches corona's symmetric dealer contract: the dispatcher is
// for off-chain test harnesses, MPC bus integration tests, and
// SDK-driven dev tooling — NOT chain-genesis ceremonies. Chain-
// genesis runs a no-trusted-dealer DKG (pulsar.NewDKGSession),
// which the dispatcher does NOT expose because the DKG is
// interactive across messaging rounds and does not fit a
// single-shot procedure envelope.
// - The dispatcher runs the GF(q) wide-committee distributed key
// generation (pulsar.NewLargeDKGSession → Round1/Round2/Round3).
// There is NO trusted dealer. Each of the n parties samples its own
// secret contribution c_i, GF(q)-Shamir-shares it to every committee
// member inside an ML-KEM-768-sealed per-recipient envelope, and the
// master ML-DSA seed emerges as Σ_i c_i. NO single party ever holds
// the master seed at any point during keygen: each party learns only
// its own aggregate Shamir share f(x_i) = Σ_i f_i(x_i) and the public
// group key. This is the owner's-law path: "remove trusted-dealer,
// not allowed" — the prior v0.3 pulsar.DealAlgebraicV03Shares dealer
// is GONE. The same dealerless DKG is what a permissioned-consortium
// / audit-attestation deployment runs; consensus chain-genesis uses
// the same primitive driven across the messaging bus instead of
// in-process.
//
// Trust model on sign:
// ----------------------------------------------------------------------
// Trust model on SIGN — t-of-n threshold, reconstruct-at-combine.
// ----------------------------------------------------------------------
//
// - The 2-round-with-w-reveal protocol (Round1 → Round2W →
// Round2Sign → AlgebraicAggregate{,Ctx}) runs in-process across
// all t signers in the session identified by pubKeyHex via
// pulsar.OrchestrateV03Sign (empty ctx) or
// pulsar.OrchestrateV03SignCtx (ctx-bound). Both APIs reduce to
// the same algebraic-aggregate kernel; the ctx variant threads
// the FIPS 204 §5.4 octet-string into the SHAKE-256 μ prehash
// so the output verifies under VerifyCtx(pub, msg, ctx, sig).
// The dispatcher returns the aggregated PULS-framed wire bytes;
// callers MUST verify via VerifyBytes (or pulsar.VerifyBytes /
// pulsar.VerifyCtx) using ONLY the published PULG-framed group
// public key.
// - Rejection-restart: FIPS 204's natural restart probability is
// ~5 attempts; we cap at params.MaxRestart (256, abort
// probability < 2^-512). The whole loop lives inside
// OrchestrateV03Sign{,Ctx} so the dispatcher stays thin.
// - NO master sk is materialised in the dispatcher process at
// any point during Sign or Sign_Ctx. The historical dealerKey
// single-party shortcut was deleted at pulsar v1.1.0; both
// Sign and Sign_Ctx now run the full algebraic-aggregate path.
// Pinned by TestAlgebraic_NoSkAccess/AlgebraicAggregateCtx in
// luxfi/pulsar.
// - The 2-round protocol (NewLargeThresholdSigner → Round1 → Round2)
// runs in-process across the t signers in the quorum, then
// pulsar.LargeCombine aggregates the t Round-2 reveals into ONE
// FIPS 204 ML-DSA-65 signature. A single party CANNOT produce the
// signature alone — t valid Round-2 reveals are required, so the
// threshold guarantee is genuine.
//
// Trust model on verify:
// - HONEST CRYPTOGRAPHIC NOTE (load-bearing for the strict-PQ gate
// below): LargeCombine is a RECONSTRUCT-style combiner. It Lagrange-
// interpolates the t revealed GF(q) shares to recover the master
// seed, derives the full ML-DSA secret key (KeyFromSeed), signs with
// it, and immediately zeroizes it. So unlike the no-reconstruct
// TALUS BCC/CEF distributed signer (which never materialises the
// master sk anywhere), this path TRANSIENTLY materialises the master
// secret key in the combiner's memory at every sign. For the
// in-process dispatcher — where all n shares already co-reside in one
// process and the role is off-chain dev tooling / MPC-bus integration
// tests / SDK-driven harnesses, NOT chain-genesis finality — this
// changes nothing: the master key is reconstructible here regardless
// of which combiner is used. It DOES matter for a real distributed
// deployment, which is exactly why the strict-PQ gate refuses this
// path (see Sign_Ctx_Profile).
//
// - Stateless: VerifyBytes(gkBytes, msg, sigBytes). No
// per-session state is consulted; the supplied PULG-framed
// group public key bytes are the authority.
// - One ctx-aware path. The plain Sign (empty ctx) and the ctx-bound
// Sign_Ctx collapse into a single largeSign helper that threads the
// FIPS 204 §5.2 ctx octet string into LargeCombine; ctx=nil yields
// the empty-context signature. The dispatcher returns the PULS-framed
// wire bytes; callers MUST verify via VerifyBytes (or pulsar.VerifyCtx)
// using ONLY the published PULG-framed group public key.
//
// ----------------------------------------------------------------------
// Trust model on VERIFY.
// ----------------------------------------------------------------------
//
// - Stateless: VerifyBytes(gkBytes, msg, sigBytes). No per-session
// state is consulted; the supplied PULG-framed group public key
// bytes are the authority. This is the contract independent peers
// (other mpcd, bridge nodes, on-chain verifier precompiles) satisfy.
type pulsarScheme struct {
mu sync.Mutex
sessions map[string]*pulsarSession
}
// pulsarSession holds the in-process per-party state for a single
// v0.3 algebraic-aggregate keygen output.
// pulsarSession holds the in-process per-party state produced by one
// dealerless committee-DKG keygen, keyed in the scheme by the PULG-framed
// group public key hex.
type pulsarSession struct {
threshold int
// setup carries the algebraic-aggregate public material (group
// public key, ρ, tr, A). NO master sk material — enforced
// AST-structurally by TestAlgebraic_SetupHasNoSkField.
setup *pulsar.AlgebraicSetup
// groupPK is the dealerless-derived FIPS 204 ML-DSA-65 group public
// key. Every committee member's DKG output agrees on this value.
groupPK *pulsar.PublicKey
// shares are the per-party AlgebraicKeyShare values. The
// dispatcher retains them in-process; subsequent Sign calls
// reference the session via pubKeyHex (the PULG-framed group
// public key bytes). This avoids exposing raw share polynomials
// over the wire.
shares []*pulsar.AlgebraicKeyShare
// committee is the canonical byte-ascending NodeID committee (matches
// pulsar's internal canonicalisation). The signing quorum is the
// first `threshold` entries.
committee []pulsar.NodeID
// quorum is the canonical t-element committee (sorted by NodeID).
quorum []pulsar.NodeID
// shareByID maps each committee member's NodeID to its GF(q) aggregate
// Shamir key share (the dealerless DKG output). The dispatcher retains
// these in-process; raw share material never crosses the wire.
shareByID map[pulsar.NodeID]*pulsar.LargeKeyShare
// quorumShares is shares[0:threshold] cached in the order quorum
// references them, so Sign can hand them to OrchestrateV03Sign
// without re-indexing.
quorumShares []*pulsar.AlgebraicKeyShare
// allShares is every committee member's share, in canonical committee
// order. LargeCombine consumes the full slice for its NodeID→eval-point
// mapping (it reads only the eval points, never the secret lanes).
allShares []*pulsar.LargeKeyShare
// evalPoints carries the precomputed Shamir x-coordinates for
// the quorum (V03QuorumEvalPoints output).
evalPoints []uint32
// identities holds each committee member's long-term ML-KEM-768
// + ML-DSA-65 identity. In production each party would hold its
// own keypair in HSM; in the dispatcher they all live in-process.
// identities holds each committee member's long-term ML-KEM-768 +
// ML-DSA-65 identity. In production each party holds its own keypair
// in HSM / KMS; in the in-process dispatcher they all live here so the
// per-pair authenticated session-key establishment can run locally.
identities map[pulsar.NodeID]*pulsar.IdentityKey
// sessionCounter increments per Sign call so distinct messages
// signed under the same group key use distinct sessionIDs (matches
// pulsar's per-signature freshness contract). Bumped inside the
// dispatcher mutex.
// sessionCounter increments per Sign call so distinct messages signed
// under the same group key use distinct sessionIDs (pulsar's
// per-signature freshness contract). Bumped inside the scheme mutex.
sessionCounter uint64
}
// sessionID call-site tags (trailing 8 bytes of the 16-byte sessionID).
// The leading 8 bytes carry the per-session freshness counter; these tags
// only make a captured wire trace unambiguously attributable to the plain
// Sign vs the ctx-bound Sign_Ctx call site.
const (
pulsarTagSignPlain uint64 = 0xDEADBEEFCAFEBABE
pulsarTagSignCtx uint64 = 0xC1C1BAB1ECAFE505
)
func newPulsarScheme() *pulsarScheme {
return &pulsarScheme{sessions: make(map[string]*pulsarSession)}
}
// Keygen runs pulsar.DealAlgebraicV03Shares for t-of-n, generates
// per-party long-term identities for the symmetric-session layer,
// Keygen runs the DEALERLESS GF(q) committee DKG
// (pulsar.NewLargeDKGSession → Round1/Round2/Round3) for t-of-n,
// publishes the canonical PULG-framed group public key bytes as
// PublicKey, and returns one decimal eval-point per party in Shares.
//
// The Shares slice contains the per-party AlgebraicKeyShare
// eval-point INDICES (decimal), not the raw secret material. The
// dispatcher retains the actual share polynomials in-process keyed
// by the PublicKey hex; subsequent Sign calls reference the session
// via PubKeyHex. This matches the corona scheme's contract.
// NO trusted dealer: every party samples and Shamir-shares its own
// contribution; no single party ever holds the master ML-DSA seed during
// the ceremony. The dispatcher retains the per-party aggregate Shamir
// shares in-process keyed by the PublicKey hex; subsequent Sign calls
// reference the session via PubKeyHex. Raw share material never crosses
// the wire — the Shares slice carries only eval-point indices.
func (s *pulsarScheme) Keygen(p keygenParams) (keygenResult, error) {
if err := validateKeygenParams(p); err != nil {
return keygenResult{}, err
@@ -141,9 +162,11 @@ func (s *pulsarScheme) Keygen(p keygenParams) (keygenResult, error) {
params := pulsar.MustParamsFor(pulsar.ModeP65)
// Generate committee NodeIDs deterministically from a fresh
// session salt. Distinct NodeIDs across sessions so concurrent
// keygens cannot alias.
// Build a committee of n distinct NodeIDs from a fresh session salt,
// then canonicalise (byte-ascending) so the dispatcher's positional
// view matches pulsar's internal committee canonicalisation and the
// signing quorum is deterministic. Distinct salts across keygens stop
// concurrent sessions from aliasing.
var sessionSalt [32]byte
if _, err := rand.Read(sessionSalt[:]); err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: salt entropy: %w", err)
@@ -151,132 +174,234 @@ func (s *pulsarScheme) Keygen(p keygenParams) (keygenResult, error) {
committee := make([]pulsar.NodeID, p.Participants)
for i := 0; i < p.Participants; i++ {
var idBuf [4]byte
binary.BigEndian.PutUint32(idBuf[:], uint32(i+1)) // 1-indexed; index 0 forbidden
binary.BigEndian.PutUint32(idBuf[:], uint32(i+1)) // 1-indexed
seed := sha256.Sum256(append(append([]byte{}, sessionSalt[:]...), idBuf[:]...))
committee[i] = pulsar.NodeID(seed)
}
sort.Slice(committee, func(i, j int) bool {
return bytes.Compare(committee[i][:], committee[j][:]) < 0
})
// Generate per-party identities (ML-KEM-768 + ML-DSA-65) for the
// symmetric-session layer that authenticates the round-1 MAC keys.
// Per-party long-term identities (ML-KEM-768 + ML-DSA-65) + directory.
// The DKG seals per-recipient envelopes under each recipient's KEM
// public key, so the directory must cover every committee member.
identities := make(map[pulsar.NodeID]*pulsar.IdentityKey, len(committee))
pubs := make(map[pulsar.NodeID]*pulsar.IdentityPublicKey, len(committee))
for _, id := range committee {
ident, err := pulsar.GenerateIdentity(rand.Reader)
if err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: GenerateIdentity: %w", err)
}
identities[id] = ident
pubs[id] = ident.PublicKey()
}
directory, err := pulsar.NewIdentityDirectory(pubs)
if err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: NewIdentityDirectory: %w", err)
}
// Master seed for this session — feeds the v0.3 trusted dealer and
// is wiped immediately after the shares are produced. After this
// point NO master-sk-bearing material exists anywhere in this
// process: Sign_Ctx now runs the full algebraic-aggregate threshold
// loop (pulsar v1.1.0 OrchestrateV03SignCtx), so there is no
// dispatcher-retained dealerKey to reconstruct sk from.
var masterSeed [pulsar.SeedSize]byte
if _, err := rand.Read(masterSeed[:]); err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: master seed entropy: %w", err)
// ---- DEALERLESS committee DKG: 3 rounds across all n parties. ----
sessions := make([]*pulsar.LargeDKGSession, len(committee))
for i, id := range committee {
ds, err := pulsar.NewLargeDKGSession(params, committee, p.Threshold, id, identities[id], directory, rand.Reader)
if err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: NewLargeDKGSession[%d]: %w", i, err)
}
sessions[i] = ds
}
setup, shares, err := pulsar.DealAlgebraicV03Shares(params, committee, p.Threshold, masterSeed, rand.Reader)
for i := range masterSeed {
masterSeed[i] = 0
r1 := make([]*pulsar.LargeDKGRound1Msg, len(sessions))
for i, ds := range sessions {
m, err := ds.Round1()
if err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: DKG Round1[%d]: %w", i, err)
}
r1[i] = m
}
if err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: DealAlgebraicV03Shares: %w", err)
r2 := make([]*pulsar.LargeDKGRound2Msg, len(sessions))
for i, ds := range sessions {
m, err := ds.Round2(r1)
if err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: DKG Round2[%d]: %w", i, err)
}
r2[i] = m
}
outs := make([]*pulsar.LargeDKGOutput, len(sessions))
for i, ds := range sessions {
out, err := ds.Round3(r1, r2)
if err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: DKG Round3[%d]: %w", i, err)
}
if out.AbortEvidence != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: DKG aborted at party %d: %v (accuser=%x accused=%x)",
i, out.AbortEvidence.Kind, out.AbortEvidence.Accuser[:4], out.AbortEvidence.Accused[:4])
}
outs[i] = out
}
// Canonical quorum: the first t shares (DealAlgebraicV03Shares
// returns shares in ascending-NodeID order, which matches the
// canonical quorum order pulsar consumes).
quorum := make([]pulsar.NodeID, p.Threshold)
quorumShares := make([]*pulsar.AlgebraicKeyShare, p.Threshold)
for i := 0; i < p.Threshold; i++ {
quorum[i] = shares[i].NodeID
quorumShares[i] = shares[i]
groupPK := outs[0].GroupPubkey
if groupPK == nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: DKG produced nil group public key")
}
evalPoints, err := pulsar.V03QuorumEvalPoints(quorum, quorumShares)
if err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: V03QuorumEvalPoints: %w", err)
// Consistency: every party MUST derive the same group public key. A
// divergence is a DKG soundness failure, not a caller error.
shareByID := make(map[pulsar.NodeID]*pulsar.LargeKeyShare, len(outs))
allShares := make([]*pulsar.LargeKeyShare, len(outs))
for i, out := range outs {
if out.GroupPubkey == nil || !out.GroupPubkey.Equal(groupPK) {
return keygenResult{}, fmt.Errorf("pulsar keygen: party %d derived a divergent group key (DKG inconsistency)", i)
}
if out.SecretShare == nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: party %d produced nil secret share", i)
}
shareByID[out.SecretShare.NodeID] = out.SecretShare
allShares[i] = out.SecretShare
}
gkBytes, err := setup.Pub.MarshalBinary()
gkBytes, err := groupPK.MarshalBinary()
if err != nil {
return keygenResult{}, fmt.Errorf("pulsar keygen: setup.Pub.MarshalBinary: %w", err)
return keygenResult{}, fmt.Errorf("pulsar keygen: groupPK.MarshalBinary: %w", err)
}
pkHex := hex.EncodeToString(gkBytes)
s.mu.Lock()
s.sessions[pkHex] = &pulsarSession{
threshold: p.Threshold,
setup: setup,
shares: shares,
quorum: quorum,
quorumShares: quorumShares,
evalPoints: evalPoints,
identities: identities,
threshold: p.Threshold,
groupPK: groupPK,
committee: committee,
shareByID: shareByID,
allShares: allShares,
identities: identities,
}
s.mu.Unlock()
shareIDs := make([]string, len(shares))
for i := range shares {
shareIDs[i] = fmt.Sprintf("%d", shares[i].EvalPoint)
shareIDs := make([]string, len(allShares))
for i := range allShares {
shareIDs[i] = fmt.Sprintf("%d", allShares[i].EvalPoint)
}
return keygenResult{PublicKey: pkHex, Shares: shareIDs}, nil
}
// Sign drives the v0.3 algebraic-aggregate protocol for the t
// signers in the session and returns the PULS-framed signature wire
// bytes. The output is bit-identical to a single-party FIPS 204
// ML-DSA signature on the same (message, group public key) — any
// caller holding the corresponding PULG-framed group public key can
// verify via VerifyBytes (or pulsar.VerifyBytes).
// largeSign drives the dealerless-keygen t-of-n threshold sign for the
// session identified by pubKeyHex and returns ONE FIPS 204 ML-DSA-65
// signature bound to ctx (ctx=nil ⇒ empty-context signature).
//
// It is the single code path behind both Sign (ctx=nil) and the ctx-bound
// Sign_Ctx family: NewLargeThresholdSigner → Round1 → Round2 →
// LargeCombine, with the t-quorum's per-pair session keys established via
// authenticated ML-KEM-768 key agreement (pulsar.SymmetricSession).
//
// callTag only tags the sessionID trailing bytes for wire-trace
// attribution; freshness comes from the per-session counter.
func (s *pulsarScheme) largeSign(pubKeyHex string, msg, ctx []byte, callTag uint64) (*pulsar.Signature, *pulsar.Params, *pulsar.PublicKey, error) {
s.mu.Lock()
sess, ok := s.sessions[pubKeyHex]
if !ok {
s.mu.Unlock()
return nil, nil, nil, fmt.Errorf("unknown pubKeyHex (keygen first)")
}
sess.sessionCounter++
counter := sess.sessionCounter
threshold := sess.threshold
groupPK := sess.groupPK
committee := append([]pulsar.NodeID(nil), sess.committee...)
shareByID := sess.shareByID
allShares := append([]*pulsar.LargeKeyShare(nil), sess.allShares...)
identities := sess.identities
s.mu.Unlock()
if threshold <= 0 || threshold > len(committee) {
return nil, nil, nil, fmt.Errorf("session has invalid threshold %d for committee %d", threshold, len(committee))
}
params := pulsar.MustParamsFor(groupPK.Mode)
// Canonical quorum: the first t committee members (committee is already
// byte-ascending). Any t-subset reconstructs; the first t is
// deterministic.
quorum := append([]pulsar.NodeID(nil), committee[:threshold]...)
// Distinct sessionID per Sign call (freshness across concurrent signs
// under the same group key). Trailing 8 bytes tag the call site.
var sid [16]byte
binary.BigEndian.PutUint64(sid[:8], counter)
binary.BigEndian.PutUint64(sid[8:], callTag)
attempt := uint32(0)
// Per-pair authenticated session keys for the quorum (ML-KEM-768
// encapsulation + ML-DSA-65 authentication). Bound to (sid, msg); ctx
// enters later, at LargeCombine's μ derivation.
sessionKeys, err := pulsarQuorumSessionKeys(quorum, identities, sid, msg)
if err != nil {
return nil, nil, nil, fmt.Errorf("session keys: %w", err)
}
// Build the t threshold signers.
signers := make([]*pulsar.LargeThresholdSigner, threshold)
for i, id := range quorum {
share, ok := shareByID[id]
if !ok {
return nil, nil, nil, fmt.Errorf("missing key share for quorum member %x", id[:4])
}
ts, err := pulsar.NewLargeThresholdSigner(params, sid, attempt, quorum, share, sessionKeys[id], msg, rand.Reader)
if err != nil {
return nil, nil, nil, fmt.Errorf("NewLargeThresholdSigner[%d]: %w", i, err)
}
signers[i] = ts
}
// Round 1: commit broadcasts.
tsR1 := make([]*pulsar.LargeRound1Message, threshold)
for i, ts := range signers {
m, err := ts.Round1(msg)
if err != nil {
return nil, nil, nil, fmt.Errorf("sign Round1[%d]: %w", i, err)
}
tsR1[i] = m
}
// Round 2: MAC-checked (mask, masked-share) reveals.
tsR2 := make([]*pulsar.LargeRound2Message, threshold)
for i, ts := range signers {
m, ev, err := ts.Round2(tsR1)
if err != nil {
if ev != nil {
return nil, nil, nil, fmt.Errorf("sign Round2[%d]: %w (abort: %v accused=%x)", i, err, ev.Kind, ev.Accused[:4])
}
return nil, nil, nil, fmt.Errorf("sign Round2[%d]: %w", i, err)
}
tsR2[i] = m
}
// Combine the t reveals into one FIPS 204 ML-DSA-65 signature bound
// to ctx (ctx=nil ⇒ empty context). randomized=false ⇒ deterministic
// FIPS 204 hedged-but-derandomised path.
sig, err := pulsar.LargeCombine(params, groupPK, msg, ctx, false, sid, attempt, quorum, threshold, tsR1, tsR2, allShares)
if err != nil {
return nil, nil, nil, fmt.Errorf("LargeCombine: %w", err)
}
return sig, params, groupPK, nil
}
// Sign drives the dealerless-keygen threshold protocol (empty ctx) for the
// quorum in the session and returns the PULS-framed signature wire bytes.
// The output is bit-identical to a single-party FIPS 204 ML-DSA-65
// signature on (message, group public key) under the empty context — any
// caller holding the corresponding PULG-framed group public key verifies
// via VerifyBytes (or pulsar.Verify).
func (s *pulsarScheme) Sign(p signParams) (signResult, error) {
msg, err := hex.DecodeString(p.MessageHex)
if err != nil {
return signResult{}, fmt.Errorf("messageHex: %w", err)
}
s.mu.Lock()
sess, ok := s.sessions[p.PubKeyHex]
if !ok {
s.mu.Unlock()
return signResult{}, fmt.Errorf("pulsar sign: unknown pubKeyHex (keygen first)")
}
sess.sessionCounter++
counter := sess.sessionCounter
setup := sess.setup
quorum := append([]pulsar.NodeID(nil), sess.quorum...)
quorumShares := append([]*pulsar.AlgebraicKeyShare(nil), sess.quorumShares...)
evalPoints := append([]uint32(nil), sess.evalPoints...)
identities := sess.identities
s.mu.Unlock()
params := pulsar.MustParamsFor(setup.Mode)
// Build sessionID from the per-session counter; binds this Sign
// call to a distinct PRNG seed across concurrent Sign calls
// against the same group key.
var sessionID [16]byte
binary.BigEndian.PutUint64(sessionID[:8], counter)
binary.BigEndian.PutUint64(sessionID[8:], 0xDEADBEEFCAFEBABE) // dispatcher tag
// Compute pairwise session keys for the quorum (ML-KEM-768
// encapsulation + ML-DSA-65 authentication per pair).
sessionKeys, err := pulsar.QuorumSessionKeys(quorum, identities, sessionID, msg)
if err != nil {
return signResult{}, fmt.Errorf("pulsar sign: QuorumSessionKeys: %w", err)
}
sig, err := pulsar.OrchestrateV03Sign(params, setup, msg, sessionID,
quorum, quorumShares, evalPoints, sessionKeys, params.MaxRestart, rand.Reader)
sig, params, groupPK, err := s.largeSign(p.PubKeyHex, msg, nil, pulsarTagSignPlain)
if err != nil {
return signResult{}, fmt.Errorf("pulsar sign: %w", err)
}
// Self-verify safety belt before publishing. Refuses to return
// bytes that would fail at the caller a failure here would
// signal a kernel bug, not a caller bug.
if err := pulsar.Verify(params, setup.Pub, msg, sig); err != nil {
// Self-verify safety belt before publishing — refuses to return bytes
// that would fail at the caller (a failure here is a kernel bug, not a
// caller bug).
if err := pulsar.Verify(params, groupPK, msg, sig); err != nil {
return signResult{}, fmt.Errorf("pulsar sign: produced signature failed self-verify (kernel bug): %w", err)
}
@@ -287,71 +412,66 @@ func (s *pulsarScheme) Sign(p signParams) (signResult, error) {
return signResult{SignatureHex: hex.EncodeToString(sigBytes)}, nil
}
// Sign_Ctx is the ctx-bound permissionless signing surface for the
// pulsar dispatcher. It emits a FIPS 204 §5.4 context-bound ML-DSA
// signature on (msg, ctx) under the session's group public key, so
// callers can produce signatures that satisfy the on-chain EVM
// precompile's domain-separation contract:
// Sign_Ctx is the ctx-bound signing surface for the pulsar dispatcher. It
// emits a FIPS 204 §5.2 context-bound ML-DSA-65 signature on (msg, ctx)
// under the session's group public key, so callers can produce signatures
// that satisfy the on-chain EVM precompile's domain-separation contract:
//
// `lux-evm-precompile-mldsa-v1` → luxfi/precompile/mldsa
// (pub.VerifySignatureCtx(msg, sig, ctx))
//
// Wire bytes: PULS-framed (Signature.MarshalBinary) — bit-identical
// to a single-party FIPS 204 §5.4 ctx-bound SignTo on the same
// (master_sk, msg, ctx) tuple. Any FIPS 204 verifier holding the
// session's PULG-framed group public key bytes accepts the result
// under VerifyCtx(pub, msg, ctx, sig).
// Wire bytes: PULS-framed (Signature.MarshalBinary) — bit-identical to a
// single-party FIPS 204 §5.2 ctx-bound signature on the same (master_sk,
// msg, ctx) tuple. Any FIPS 204 verifier holding the session's PULG-framed
// group public key bytes accepts the result under VerifyCtx(pub, msg, ctx,
// sig).
//
// Path (pulsar v1.1.0+): runs the FULL algebraic-aggregate threshold
// loop via pulsar.OrchestrateV03SignCtx. NO master sk is materialised
// at any point in this process: parties hold polynomial-vector Shamir
// shares of (s_1, s_2, t_0) over GF(q); the aggregator combines
// (z, c·s_2, c·t_0) under Lagrange-linearity; the FIPS 204 §5.4 μ
// prefix carries ctx into the SHAKE-256 prehash so the output is
// byte-identical to single-party SignTo on the (existentially
// quantified) master sk. The historical dealerKey single-party
// shortcut has been deleted.
//
// signCtx is the FIPS 204 ctx octet string (0..255 bytes). Pass nil
// (or the empty hex string "") to bind the empty ctx — backwards
// compatible with v0.3 OrchestrateV03Sign byte-for-byte under the
// same deterministic seeds.
//
// NOTE on strict-PQ: this method is the legacy entry point; it does
// NOT consult the strict-PQ profile gate. Callers that produce
// signatures destined for a strict-PQ chain MUST go through the
// ZAP dispatcher (which routes via Sign_Ctx_Profile and runs the
// gate). In-process callers that want the gate consult
// RefuseUnderStrictPQ themselves before invoking Sign_Ctx.
// NOTE on strict-PQ: this is the legacy entry point and does NOT consult
// the strict-PQ profile gate. The dispatcher always routes ctx-bound signs
// through Sign_Ctx_Profile (which runs the gate); in-process embedders with
// their own admission gate may call this directly. signCtx is the FIPS 204
// ctx octet string (0..255 bytes); pass "" to bind the empty ctx
// (byte-identical to Sign).
func (s *pulsarScheme) Sign_Ctx(p signCtxParams) (signResult, error) {
return s.signCtxInternal(p)
}
// Sign_Ctx_Profile is the profile-aware entry point. The dispatcher
// always routes here when the scheme implements
// profileAwareCtxSigner (see types.go). The gate fires at entry:
// on a strict-PQ chain, the call is refused with
// ErrRefusedUnderStrictPQ (the ZAP dispatcher surfaces this as an
// error response with strictPQ=true); on any other profile, or with
// no resolver / no chain ID, the call falls through to signCtxInternal.
// Sign_Ctx_Profile is the profile-aware entry point. The dispatcher always
// routes here (pulsar implements profileAwareCtxSigner — see types.go). The
// strict-PQ gate fires at entry: on a strict-PQ chain the call is refused
// with ErrRefusedUnderStrictPQ; on any other profile, or with no resolver
// wired, it falls through to signCtxInternal.
//
// Why the gate sits HERE (not in signCtxInternal): the legacy
// Sign_Ctx is still public for in-process embedders that have
// their own outer admission gate (e.g. luxfi/mpc's API surface).
// Forcing the gate on every caller would surprise those. The
// dispatcher path — the one network-reachable surface — always
// runs the gate. One function, one place: profile.go owns the
// policy; this method owns the call site.
// ----------------------------------------------------------------------
// Why the strict-PQ gate STILL refuses pulsar (reasoned, not reflexive).
// ----------------------------------------------------------------------
//
// HISTORY: this gate guarded the dealer-shortcut shortcut (pulsar
// v1.0.x) where a single-party PrivateKey lived in pulsarSession.
// pulsar v1.1.0 deleted dealerKey; signCtxInternal now runs the
// full algebraic-aggregate threshold path with NO sk-bearing state
// in process. The strict-PQ refusal is therefore now a POLICY gate
// (operators may still want to refuse v1 ML-DSA on a strict-PQ
// chain in favour of v0.4 hybrid composition), not a cryptographic
// gate against sk leakage. We KEEP IT — strict-PQ semantics is a
// chain-level policy choice, not a kernel claim.
// The gate originally guarded the pulsar v1.0.x single-party DEALER
// shortcut (a real single-party PrivateKey lived in pulsarSession, so
// "threshold" was a lie). That shortcut is GONE: keygen is now the
// dealerless committee DKG (NewLargeDKGSession) and sign is a genuine
// t-of-n LargeCombine — one party cannot sign alone. So the SPECIFIC
// thing the gate first named no longer exists.
//
// But the gate is NOT a no-op for this path, and silently dropping it
// would delete real safety. The v1.2.0 LargeCombine is a RECONSTRUCT-style
// combiner: it Lagrange-interpolates the master seed, derives the full
// ML-DSA secret key, signs, and wipes it — TRANSIENTLY materialising the
// master sk in one party's memory at every sign. That is strictly weaker
// than the no-reconstruct TALUS BCC/CEF distributed signer (which never
// forms the master sk anywhere). A strict-PQ chain — the highest-assurance
// tier — must source its ctx-bound, precompile-acceptable finality
// signatures from the consensus-driven distributed no-reconstruct
// ceremony, NOT from this in-process dev-tooling dispatcher whose combiner
// reconstructs the key. The gate is the policy boundary that enforces that
// separation, so it stays. (For the dispatcher's own in-process model the
// reconstruct is moot — all shares already co-reside — which is precisely
// why the dispatcher is dev tooling and not a strict-PQ finality source.)
//
// One function, one place: profile.go owns the policy
// (RefuseUnderStrictPQ); this method owns the call site. Non-strict and
// no-resolver deployments fall straight through to the real dealerless
// threshold sign.
func (s *pulsarScheme) Sign_Ctx_Profile(p signCtxParams, resolver ChainProfileResolver) (signResult, error) {
if err := RefuseUnderStrictPQ(p.ChainID, "pulsar.sign_ctx", resolver); err != nil {
return signResult{}, err
@@ -359,18 +479,9 @@ func (s *pulsarScheme) Sign_Ctx_Profile(p signCtxParams, resolver ChainProfileRe
return s.signCtxInternal(p)
}
// signCtxInternal is the v1.1.0 ctx-bound algebraic-aggregate path.
// Drives the full Round1 → Round2W → Round2Sign → AlgebraicAggregateCtx
// loop with the supplied ctx threaded into the FIPS 204 §5.4 step-2 μ
// prehash. NO master sk is materialised at any point in this function
// or in the per-party state machines — parties hold polynomial-vector
// Shamir shares of (s_1, s_2, t_0) over GF(q); the aggregator combines
// (z, c·s_2, c·t_0) under Lagrange-linearity (TestAlgebraic_NoSkAccess/
// AlgebraicAggregateCtx pins this AST-structurally upstream).
//
// The empty-ctx case (CtxHex == "") is byte-identical to Sign(msg) —
// pulsar.OrchestrateV03Sign is now a wrapper around
// OrchestrateV03SignCtx(nil, msg).
// signCtxInternal is the ctx-bound dealerless threshold path. It decodes
// the ctx octet string and drives the single largeSign helper; the empty
// ctx case (CtxHex == "") is byte-identical to Sign(msg).
func (s *pulsarScheme) signCtxInternal(p signCtxParams) (signResult, error) {
msg, err := hex.DecodeString(p.MessageHex)
if err != nil {
@@ -384,52 +495,14 @@ func (s *pulsarScheme) signCtxInternal(p signCtxParams) (signResult, error) {
}
}
s.mu.Lock()
sess, ok := s.sessions[p.PubKeyHex]
if !ok {
s.mu.Unlock()
return signResult{}, fmt.Errorf("pulsar sign_ctx: unknown pubKeyHex (keygen first)")
}
sess.sessionCounter++
counter := sess.sessionCounter
setup := sess.setup
quorum := append([]pulsar.NodeID(nil), sess.quorum...)
quorumShares := append([]*pulsar.AlgebraicKeyShare(nil), sess.quorumShares...)
evalPoints := append([]uint32(nil), sess.evalPoints...)
identities := sess.identities
s.mu.Unlock()
params := pulsar.MustParamsFor(setup.Mode)
// Build sessionID from the per-session counter; binds this Sign_Ctx
// call to a distinct PRNG seed across concurrent calls against the
// same group key. The trailing 8 bytes tag this as the ctx-bound
// dispatcher path so a captured wire trace is unambiguously
// attributable to Sign_Ctx vs Sign.
var sessionID [16]byte
binary.BigEndian.PutUint64(sessionID[:8], counter)
binary.BigEndian.PutUint64(sessionID[8:], 0xC1C1BAB1ECAFE505) // sign_ctx tag
// Compute pairwise session keys for the quorum (ML-KEM-768
// encapsulation + ML-DSA-65 authentication per pair). The
// transcript MUST bind the message but NOT the ctx — session
// keys are per-(sid, msg); ctx enters at the μ derivation
// inside the v0.4 algebraic-aggregate loop.
sessionKeys, err := pulsar.QuorumSessionKeys(quorum, identities, sessionID, msg)
if err != nil {
return signResult{}, fmt.Errorf("pulsar sign_ctx: QuorumSessionKeys: %w", err)
}
sig, err := pulsar.OrchestrateV03SignCtx(params, setup, signCtx, msg, sessionID,
quorum, quorumShares, evalPoints, sessionKeys, params.MaxRestart, rand.Reader)
sig, params, groupPK, err := s.largeSign(p.PubKeyHex, msg, signCtx, pulsarTagSignCtx)
if err != nil {
return signResult{}, fmt.Errorf("pulsar sign_ctx: %w", err)
}
// Self-verify safety belt: refuse to publish bytes that would
// fail at the caller. Uses pulsar.VerifyCtx so ctx-binding is
// covered.
if err := pulsar.VerifyCtx(params, setup.Pub, msg, signCtx, sig); err != nil {
// Self-verify safety belt refuses to publish bytes that would fail
// at the caller. Uses VerifyCtx so ctx binding is covered.
if err := pulsar.VerifyCtx(params, groupPK, msg, signCtx, sig); err != nil {
return signResult{}, fmt.Errorf("pulsar sign_ctx: produced signature failed self-verify (kernel bug): %w", err)
}
@@ -440,14 +513,13 @@ func (s *pulsarScheme) signCtxInternal(p signCtxParams) (signResult, error) {
return signResult{SignatureHex: hex.EncodeToString(sigBytes)}, nil
}
// Verify is stateless: it decodes the supplied PULG-framed group
// public key + PULS-framed signature wire bytes and runs the
// pulsar kernel's stateless VerifyBytes.
// Verify is stateless: it decodes the supplied PULG-framed group public
// key + PULS-framed signature wire bytes and runs the pulsar kernel's
// stateless VerifyBytes (raw FIPS 204 ML-DSA-65 under the empty ctx).
//
// The dispatcher does NOT consult any in-process session — the
// supplied PubKeyHex IS the authority. This is the contract that
// independent peers (other mpcd, bridge nodes, L1 verifier
// contracts) must satisfy.
// The dispatcher does NOT consult any in-process session — the supplied
// PubKeyHex IS the authority. This is the contract that independent peers
// (other mpcd, bridge nodes, L1 verifier contracts) must satisfy.
func (s *pulsarScheme) Verify(p verifyParams) (verifyResult, error) {
msg, err := hex.DecodeString(p.MessageHex)
if err != nil {
@@ -463,3 +535,43 @@ func (s *pulsarScheme) Verify(p verifyParams) (verifyResult, error) {
}
return verifyResult{OK: pulsar.VerifyBytes(gkBytes, msg, sigBytes)}, nil
}
// pulsarQuorumSessionKeys establishes the per-pair authenticated session
// keys for the quorum, mirroring the upstream test fixture's
// quorumSessionKeys: for every unordered pair {a,b} it runs
// pulsar.SymmetricSession (authenticated ML-KEM-768 key agreement) and
// records the byte-identical key under both parties' views. The returned
// map is keyed [me][peer] → 32-byte session key; NewLargeThresholdSigner
// consumes sessionKeys[me].
//
// In a real distributed deployment each party drives Round 0 of the
// identity KEM exchange over the message bus; the in-process dispatcher
// runs both sides locally because it holds every identity key.
func pulsarQuorumSessionKeys(
quorum []pulsar.NodeID,
identities map[pulsar.NodeID]*pulsar.IdentityKey,
sid [16]byte,
transcript []byte,
) (map[pulsar.NodeID]map[pulsar.NodeID][32]byte, error) {
out := make(map[pulsar.NodeID]map[pulsar.NodeID][32]byte, len(quorum))
for _, id := range quorum {
out[id] = make(map[pulsar.NodeID][32]byte, len(quorum)-1)
}
for i := 0; i < len(quorum); i++ {
for j := i + 1; j < len(quorum); j++ {
a, b := quorum[i], quorum[j]
ka, oka := identities[a]
kb, okb := identities[b]
if !oka || !okb {
return nil, fmt.Errorf("missing identity for session pair %x/%x", a[:4], b[:4])
}
key, err := pulsar.SymmetricSession(a, ka, b, kb, sid, transcript)
if err != nil {
return nil, fmt.Errorf("SymmetricSession %x<->%x: %w", a[:4], b[:4], err)
}
out[a][b] = key
out[b][a] = key
}
}
return out, nil
}