Files
zeekay d44e84ac04 gate-b: fix lss corona-Round1 drift, drop dead parity bench, correct v0.3 docs
- protocols/lss/lss_pulsar_test.go: corona v0.8.0 Signer.Round1 now
  returns (*Round1Data, error); capture the error (was assign-1-of-2).
- protocols/parity/parity_test.go: DELETED. It benchmarked the removed
  v0.1 pulsar.ThresholdSigner/Combine small-committee API (ripped in H-1,
  gone in pulsar v1.2.0). No backwards compat — dead bench for deleted
  code is removed, not resurrected.
- pkg/thresholdd/{doc,profile,thresholdd_test}.go: correct stale comments
  that named the removed v0.3 DealAlgebraicV03Shares/OrchestrateV03SignCtx
  symbols; describe the dealerless committee-DKG + reconstruct-at-combine
  reality and why the strict-PQ gate stays load-bearing.
2026-06-27 19:25:49 -07:00

221 lines
9.3 KiB
Go

// SPDX-License-Identifier: BSD-3-Clause
package thresholdd
import (
"errors"
)
// profile.go — chain-profile gate.
//
// One function, one place. The gate (RefuseUnderStrictPQ) is the
// single policy boundary the dispatcher consults to decide whether
// the single-party dealer shortcut on Sign_Ctx is acceptable on this
// chain. Everything upstream just calls RefuseUnderStrictPQ at the
// top of the relevant operation and otherwise stays in its lane —
// no policy-mixing, no per-scheme gate forks.
//
// Why this lives here (and not on the embedder side):
//
// - doc.go's "this package contains zero policy" note still holds
// for routing / auth / audit / per-method admission. The
// strict-PQ vs single-party-shortcut decision IS NOT routing
// policy — it is a primitive-soundness guard tightly coupled to
// pulsar v0.3's documented limitation (empty-ctx-only) and
// magnetar's single-validator-shortcut on Sign_Ctx. Refusing
// these under strict-PQ has to happen at the call site that
// produces the signature, not at an outer routing layer that
// does not know which signing path the dispatcher chose.
//
// Why this is a thin abstraction (not a registry import):
//
// - luxfi/threshold MUST NOT pick up a dependency on
// luxfi/consensus/config (that module pulls validators, FPC,
// bridge profile, etc. — far more than this gate needs and a
// painful cascade on every consensus bump). Instead we expose a
// tiny ChainProfileResolver interface; the embedder (luxfi/mpc
// or luxfi/node) supplies an adapter that calls
// config.ProfileByID under the hood.
//
// Boundary contract:
//
// - StrictPQ profile: NO single-party dealer/single-validator
// shortcut on Sign_Ctx (or any other path that would surface a
// bit-equal classical-shape signature without an actual
// threshold-bound ctx). Gate RETURNS ErrRefusedUnderStrictPQ so
// the caller knows the operation is temporarily refused pending
// sister-agent's pulsar v0.4 ctx-bound path landing. The ZAP
// dispatcher surfaces this as an error message with strictPQ=true
// so the client can errors.Is the sentinel.
// - LegacyCompat / Permissive profile: shortcuts acceptable as
// documented dev-tooling. Operators are responsible for not
// building production on top of permissive profiles.
// - Unknown profile (no resolver wired OR resolver returned no
// match): fail-OPEN. The dispatcher predates strict-PQ
// deployment and a missing resolver would otherwise refuse
// every Sign_Ctx call ever made. Documented in
// RefuseUnderStrictPQ — embedders that want fail-closed wire
// their own outer admission gate.
//
// Why the gate is STILL load-bearing for pulsar (post-v1.2.0):
//
// - The pulsar dispatcher's single-party DEALER shortcut is GONE —
// keygen is now the dealerless committee DKG (NewLargeDKGSession)
// and sign is a genuine t-of-n LargeCombine. But LargeCombine is a
// RECONSTRUCT-style combiner: it transiently materialises the master
// ML-DSA secret key in one party's memory at every sign (Lagrange-
// interpolate seed → KeyFromSeed → sign → zeroize). That is strictly
// weaker than the no-reconstruct TALUS BCC/CEF distributed signer.
// A strict-PQ chain must source ctx-bound finality signatures from
// the consensus-driven no-reconstruct ceremony, NOT from this
// in-process dev-tooling dispatcher whose combiner reconstructs the
// key — so the gate stays load-bearing on pulsar.go::Sign_Ctx_Profile
// (see the reasoning there). The magnetar gate likewise vanishes only
// when magnetar Sign_Ctx drives a per-validator standalone
// aggregate-cert path instead of routing through keys[0]. One
// function, one place — outliving any particular primitive's
// evolution.
// Profile is the chain-wide security-class label this dispatcher
// reasons about. Three buckets are enough — the consensus toolkit
// distinguishes finer (StrictPQ / Permissive / FIPS), but the
// dispatcher only needs "is this strict-PQ?" to fire the gate.
type Profile uint8
const (
// ProfileUnknown means no resolver returned a profile for the
// chain ID (or no resolver was wired). The gate fails OPEN under
// this profile — see package doc for the rationale.
ProfileUnknown Profile = 0
// ProfileStrictPQ corresponds to luxfi/consensus/config
// ProfileStrictPQ (0x01) AND ProfileFIPS (0x03) — the profiles
// whose IsPQ() returns true. Either profile demands a refusal
// here because both forbid the single-party dealer shortcut on
// any classical-shape primitive.
ProfileStrictPQ Profile = 1
// ProfileLegacyCompat covers permissive and any non-strict-PQ
// profile that explicitly accepts dealer-derived shortcuts (the
// dispatcher's documented dev-tooling role). Gate lets the call
// through.
ProfileLegacyCompat Profile = 2
)
// String returns the canonical lowercase profile name (mirrors
// luxfi/consensus/config.ProfileID.String).
func (p Profile) String() string {
switch p {
case ProfileStrictPQ:
return "strict-PQ"
case ProfileLegacyCompat:
return "legacy-compat"
default:
return "unknown"
}
}
// ChainProfileResolver maps a chain ID (the ChainID field on
// signCtxParams, transported in the ZAP SignCtxRequest envelope) to
// the chain's active Profile. The resolver lives on the embedder side so this
// package does not import luxfi/consensus/config; luxfi/mpc and
// luxfi/node each wire their own resolver from genesis manifest /
// runtime profile state.
//
// Implementations MUST be safe for concurrent use. Returning
// ProfileUnknown for an unrecognised chainID is the documented
// fail-OPEN signal.
type ChainProfileResolver interface {
ResolveChainProfile(chainID string) Profile
}
// staticResolver is a tiny in-process resolver useful for tests and
// for the standalone thresholdd CLI (which pins one profile for the
// whole process lifetime). Production embedders supply their own
// implementation.
type staticResolver struct {
chains map[string]Profile
def Profile
}
// NewStaticChainProfileResolver builds a static resolver. chainID
// keys not in the map fall back to def. Pass def=ProfileUnknown to
// fail-OPEN for unknown chains; pass def=ProfileStrictPQ on a
// PQ-only deployment so missing entries default-refuse.
func NewStaticChainProfileResolver(def Profile, chains map[string]Profile) ChainProfileResolver {
cp := make(map[string]Profile, len(chains))
for k, v := range chains {
cp[k] = v
}
return &staticResolver{chains: cp, def: def}
}
func (r *staticResolver) ResolveChainProfile(chainID string) Profile {
if p, ok := r.chains[chainID]; ok {
return p
}
return r.def
}
// IsStrictPQProfile reports whether chainID maps to ProfileStrictPQ
// via the supplied resolver. Returns false when resolver is nil or
// the resolver returns any non-strict-PQ profile.
//
// This is the single-source-of-truth lookup; call sites that need
// the gate go through RefuseUnderStrictPQ which calls this under
// the hood.
func IsStrictPQProfile(chainID string, resolver ChainProfileResolver) bool {
if resolver == nil {
return false
}
return resolver.ResolveChainProfile(chainID) == ProfileStrictPQ
}
// ErrRefusedUnderStrictPQ is the sentinel returned by
// RefuseUnderStrictPQ when the operation is refused on a strict-PQ
// chain. In-process callers inspect it with errors.Is; the ZAP
// dispatcher surfaces it as an error response with strictPQ=true.
var ErrRefusedUnderStrictPQ = errors.New("operation refused on strict-PQ chain profile")
// RefuseUnderStrictPQ is THE single policy gate. Call sites that
// surface a single-party dealer / single-validator shortcut on a
// classical-shape ctx-bound primitive call this at entry; the
// function returns ErrRefusedUnderStrictPQ when the chain is on
// strict-PQ and nil otherwise.
//
// Decomplecting note: every dispatcher method that could possibly
// be refused on strict-PQ calls this ONE function exactly once at
// the top. Verification / authorisation / audit logic does NOT
// braid into the gate — they stay in their lanes. Mirrors the
// classical-precompile profile gate
// (luxfi/precompile/contract.RefuseUnderStrictPQ) so the project
// has one obvious way to express "refuse on strict-PQ".
//
// op is the human-readable operation tag (e.g. "pulsar.sign_ctx",
// "magnetar.sign_ctx"). Surfaced in the error message so audit
// logs name exactly which method tripped the gate.
//
// Returns nil — gate passes — when:
//
// - resolver is nil (no chain-profile resolver wired; dev-only
// default — production MUST wire a resolver), OR
// - resolver returns a non-strict-PQ profile.
//
// Empty chainID is NOT a pass condition: it is forwarded to the
// resolver, which maps "" to ProfileStrictPQ (fail-CLOSED), so a
// caller cannot bypass the gate by omitting the chain context.
//
// Returns ErrRefusedUnderStrictPQ wrapped with op + chainID when
// the chain is on ProfileStrictPQ. The wrapped message MUST stay
// stable so audit-log parsing downstream does not break.
func RefuseUnderStrictPQ(chainID, op string, resolver ChainProfileResolver) error {
if resolver == nil {
return nil
}
if resolver.ResolveChainProfile(chainID) != ProfileStrictPQ {
return nil
}
return errors.Join(ErrRefusedUnderStrictPQ,
errors.New(op+" refused: chain "+chainID+" is on strict-PQ profile; "+
"pulsar v0.4 threshold ctx-bound path required (see doc.go)"))
}