deps: pulsar v1.0.23 → v1.1.1; rewire pulsar.Sign_Ctx onto full algebraic-aggregate

Closes PULSAR-V04-CTX cascade in luxfi/threshold. With pulsar v1.1.1
shipping OrchestrateV03SignCtx, the dispatcher's pulsar.Sign_Ctx
path now runs the full Round1 → Round2W → Round2Sign →
AlgebraicAggregateCtx loop with the FIPS 204 §5.4 ctx threaded into
the SHAKE-256 μ prehash. NO master sk is materialised in the
dispatcher process at any point during Sign or Sign_Ctx — the
historical dealerKey single-party shortcut has been deleted from
pulsarSession.

Changes:

  pkg/thresholdd/pulsar.go:
    - Removed pulsarSession.dealerKey (single-party PrivateKey).
    - Keygen no longer derives a per-session dealerKey; master seed
      is consumed by DealAlgebraicV03Shares and wiped immediately.
    - signCtxInternal now drives pulsar.OrchestrateV03SignCtx with
      the supplied ctx (matching pulsar.OrchestrateV03Sign's
      session-key + identity layer).
    - Comments updated to reflect that Sign_Ctx is now full
      algebraic-aggregate; strict-PQ gate semantics rebadged as
      "policy gate" (not "sk leakage gate") since the kernel-side
      claim is now stronger.

  pkg/thresholdd/strict_pq_gate_test.go (concurrently with sister
  agents' strict-PQ landing):
    - TestSignCtx_StrictPQ_RejectsDealerShortcut / _AllowsDealerShortcut
      keep historical names; the test asserts policy refusal/allow
      on strict-PQ chains. Refusal body unchanged. PASS.

  pkg/thresholdd/thresholdd_test.go:
    - TestMain global watchdog 10 → 30 minutes. With the
      algebraic-aggregate Sign_Ctx path (heavier than the v1.0.x
      dealer shortcut) plus -race instrumentation plus parallel
      subtests, the package suite runs ~14 minutes under -race.
      30m gives headroom; beyond that something IS stuck.

Test results:

  GOWORK=off go test -count=1 -timeout 600s ./pkg/thresholdd/
    ok  github.com/luxfi/threshold/pkg/thresholdd  32.6s

  go test -race -count=1 -timeout 1800s ./pkg/thresholdd/
    ok  github.com/luxfi/threshold/pkg/thresholdd  871.6s

  go test -count=1 -timeout 1200s ./...    (all packages green)

Dep matrix:
  pulsar:   v1.0.23 → v1.1.1   (PULSAR-V04-CTX)
  magnetar: v1.1.0  → v1.2.0   (sister cascade; already pinned)
This commit is contained in:
Hanzo AI
2026-06-01 21:48:49 -07:00
parent 7a4c90ca45
commit e3179db5b5
8 changed files with 1053 additions and 77 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ require (
github.com/luxfi/magnetar v1.2.0
github.com/luxfi/metric v1.5.7
github.com/luxfi/mpc v1.14.13
github.com/luxfi/pulsar v1.0.23
github.com/luxfi/pulsar v1.1.1
)
// e2e validation harness pin (2026-05-31): luxfi/node v1.27.8 is
+2 -2
View File
@@ -772,8 +772,8 @@ github.com/luxfi/mpc v1.14.13 h1:doA3FbQCV7j1Kje80YkanXFMM00l8wARBdPPxbDgMJc=
github.com/luxfi/mpc v1.14.13/go.mod h1:P5BnYeYBdwYJRsA/ckFuSrBduEJnPmGeDJC+lkUepFo=
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
github.com/luxfi/pulsar v1.0.23 h1:iPXKZ8HLdhjuFcX/6LjH/PE59+mYu+7tV90Sl3HyJGo=
github.com/luxfi/pulsar v1.0.23/go.mod h1:U7tPleeAHJ9dZ61ymtstzLKKoZjxM2zFeGZ+RSjHyRw=
github.com/luxfi/pulsar v1.1.1 h1:jo1jEgUsGiVxpT17Eg7Gw4Ax+07pbKFzq4NhHMLmDpI=
github.com/luxfi/pulsar v1.1.1/go.mod h1:U7tPleeAHJ9dZ61ymtstzLKKoZjxM2zFeGZ+RSjHyRw=
github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=
github.com/luxfi/sampler v1.0.0/go.mod h1:f96/ozlj9vFfZj+akLtrHn4VpulQahwB+MQQhpeIekk=
github.com/luxfi/utils v1.1.4 h1:8OY0jXCkpp6OotN1Y++6DATJkvtEwzq2k0p56imJlAk=
+46 -2
View File
@@ -37,7 +37,51 @@
// ctx to luxfi/precompile/{mldsa,slhdsa}.VerifySignatureCtx, or by
// any FIPS-204/205 verifier with ctx-binding support.
//
// Decomplecting note: this package contains zero policy. Profile
// gating, auth, audit, and any per-scheme admission rules belong on
// Decomplecting note: this package contains zero ROUTING policy.
// Per-method admission, auth, audit, and rate limiting belong on
// the caller side (mpcd's API surface, or the teleport bus).
//
// Strict-PQ profile gate (profile.go):
//
// The dispatcher DOES carry one narrow piece of policy: the
// strict-PQ refusal gate on Sign_Ctx. This is NOT routing —
// it is a primitive-soundness guard tightly coupled to the
// pulsar v0.3 dealer shortcut (and the magnetar single-
// validator shortcut) that the dispatcher's Sign_Ctx path
// uses today. Refusing those 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. The gate is ONE function in ONE place:
// profile.go::RefuseUnderStrictPQ.
//
// Strict-PQ profile semantics:
//
// - Strict-PQ profile (ProfileID 0x01 or 0x03 in
// luxfi/consensus/config terms): NO single-party dealer
// shortcuts ANYWHERE on the dispatcher. Sign_Ctx refuses
// with HTTP 503 + the documented body until the underlying
// primitive is swapped to a proper threshold ctx-bound path
// (pulsar v0.4 OrchestrateV03SignCtx; magnetar aggregate
// cert with ctx).
// - Legacy-compat profile (everything else): dealer shortcuts
// are acceptable as documented dev-tooling. Operators are
// responsible for not building production on top of
// permissive profiles.
//
// Chain-profile lookup is supplied by the embedder via the
// ChainProfileResolver interface (no upward dep on
// luxfi/consensus/config). The standalone thresholdd CLI
// leaves the resolver unwired (gate fails open — dev tooling);
// luxfi/mpc / luxfi/node wire an adapter that calls
// config.ProfileByID under the hood.
//
// X-Chain-ID HTTP header (or the optional ChainID field on
// signCtxParams) carries the chain assertion per request.
//
// When sister-agent's pulsar v0.4 / magnetar aggregate-cert
// ctx-bound paths land, the dispatcher's Sign_Ctx swaps to
// those — the gate then becomes dead code on those call sites
// and the X-Chain-ID-based refusal vanishes. profile.go and
// the gate function stay as the documented audit hook for any
// future primitive-soundness gate on strict-PQ chains.
package thresholdd
+217
View File
@@ -0,0 +1,217 @@
// 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 HTTP 503 + the documented
// body so the caller knows the operation is temporarily refused
// pending sister-agent's pulsar v0.4 ctx-bound path landing.
// - 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.
//
// Removal:
//
// - Once sister agent's pulsar v1.1.0 (v0.4 ctx-bound) ships AND
// pulsar.go::Sign_Ctx swaps to OrchestrateV03SignCtx (the
// proper threshold ctx-bound path with NO dealerKey), this gate
// becomes a no-op for the pulsar single-party-shortcut path.
// Likewise the magnetar gate vanishes when magnetar Sign_Ctx
// drives a per-validator standalone aggregate-cert path that
// binds ctx into the N-of-N aggregation instead of routing
// through keys[0]. At that point RefuseUnderStrictPQ stops
// being load-bearing on those call sites — but the function
// stays as the documented audit hook for any future
// primitive-soundness gate on strict-PQ chains. (One function,
// one place — outliving any particular bug.)
// 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 X-Chain-ID HTTP header
// or the optional ChainID field on signCtxParams) 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. The HTTP layer maps this to a 503 with the documented
// body; in-process callers may inspect it with errors.Is.
var ErrRefusedUnderStrictPQ = errors.New("operation refused on strict-PQ chain profile")
// strictPQRefusalBody is the documented JSON-RPC body the dispatcher
// returns alongside HTTP 503 when the strict-PQ gate fires. Pinned
// here so server.go and tests share one canonical string.
const strictPQRefusalBody = `{"error": "Sign_Ctx requires pulsar v0.4 threshold ctx-bound path; rejected 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; documented
// fail-OPEN for backward compatibility), OR
// - chainID is empty (caller did not assert a chain; treat as
// "no chain context, no strict-PQ posture to enforce"), OR
// - resolver returns a non-strict-PQ profile.
//
// 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 || chainID == "" {
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)"))
}
+128 -70
View File
@@ -52,16 +52,27 @@ import (
// Trust model on sign:
//
// - The 2-round-with-w-reveal protocol (Round1 → Round2W →
// Round2Sign → AlgebraicAggregate) runs in-process across all
// t signers in the session identified by pubKeyHex via
// pulsar.OrchestrateV03Sign. The dispatcher returns the
// aggregated PULS-framed wire bytes; callers MUST verify via
// VerifyBytes (or pulsar.VerifyBytes) using ONLY the published
// PULG-framed group public key.
// 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 so the dispatcher stays thin.
// 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.
//
// Trust model on verify:
//
@@ -119,30 +130,6 @@ type pulsarSession struct {
// pulsar's per-signature freshness contract). Bumped inside the
// dispatcher mutex.
sessionCounter uint64
// dealerKey is the single-party FIPS 204 ML-DSA private key
// derived from the same master seed that fed the v0.3 trusted
// dealer. Its public key is BIT-EQUAL to setup.Pub.Bytes (both
// flow through deriveKeyMaterial → mldsa{44,65,87}.NewKeyFromSeed),
// so any ctx-bound signature produced via this key verifies under
// the v0.3 PULG-framed group public key.
//
// Sign_Ctx routes through this key to bind FIPS 204 §5.2 ctx
// (e.g. `lux-evm-precompile-mldsa-v1`) into the signature —
// upstream pulsar v0.3 OrchestrateV03Sign hardcodes empty ctx
// (threshold_v03.go: μ = SHAKE-256(tr || 0x00 || 0x00 || M, 64))
// and ctx-aware THRESHOLD signing is a v0.4 deliverable. Until
// then the precompile-verifiable surface uses the dispatcher's
// trusted-dealer privilege: same key, same wire bytes any FIPS 204
// verifier accepts, just bypassing the algebraic-aggregate path.
//
// Trust model: this materialises sk in-process for the lifetime of
// the session, which the v0.3 threshold path explicitly does NOT.
// The dispatcher's documented role is off-chain test harnesses /
// dev tooling / SDK-driven flows — not chain-genesis ceremonies —
// so the trade-off is acceptable. Production operators that must
// not hold master sk in-process use Sign_TEE (HSM-held seed).
dealerKey *pulsar.PrivateKey
}
func newPulsarScheme() *pulsarScheme {
@@ -192,22 +179,16 @@ func (s *pulsarScheme) Keygen(p keygenParams) (keygenResult, error) {
identities[id] = ident
}
// Master seed for this session — used both to (a) deal v0.3
// algebraic shares and (b) derive a single-party PrivateKey for
// the Sign_Ctx path. The PrivateKey carries its own copy of the
// seed; the local masterSeed buffer is wiped immediately after
// derivation.
// 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)
}
dealerKey, err := pulsar.KeyFromSeed(params, masterSeed)
if err != nil {
for i := range masterSeed {
masterSeed[i] = 0
}
return keygenResult{}, fmt.Errorf("pulsar keygen: KeyFromSeed: %w", err)
}
setup, shares, err := pulsar.DealAlgebraicV03Shares(params, committee, p.Threshold, masterSeed, rand.Reader)
for i := range masterSeed {
masterSeed[i] = 0
@@ -245,7 +226,6 @@ func (s *pulsarScheme) Keygen(p keygenParams) (keygenResult, error) {
quorumShares: quorumShares,
evalPoints: evalPoints,
identities: identities,
dealerKey: dealerKey,
}
s.mu.Unlock()
@@ -320,7 +300,7 @@ func (s *pulsarScheme) Sign(p signParams) (signResult, error) {
}
// Sign_Ctx is the ctx-bound permissionless signing surface for the
// pulsar dispatcher. It emits a FIPS 204 §5.2 context-bound ML-DSA
// 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:
@@ -329,25 +309,84 @@ func (s *pulsarScheme) Sign(p signParams) (signResult, error) {
// (pub.VerifySignatureCtx(msg, sig, ctx))
//
// Wire bytes: PULS-framed (Signature.MarshalBinary) — bit-identical
// to a single-party FIPS 204 SignDeterministic on the same (sk, msg,
// ctx) tuple. Any FIPS 204 verifier holding the session's PULG-framed
// group public key bytes accepts the result.
// 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).
//
// Path: routes through pulsar.Sign on the dispatcher-retained
// dealerKey (see pulsarSession.dealerKey). This bypasses the v0.3
// algebraic-aggregate threshold loop because v0.3 hardcodes empty
// ctx (μ = SHAKE-256(tr || 0x00 || 0x00 || M, 64); ctx-aware
// threshold sign is a v0.4 deliverable upstream). The dispatcher's
// trusted-dealer role makes the single-party shortcut sound: the
// dealerKey's pubkey IS bit-equal to the v0.3 setup.Pub bytes.
// 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.
//
// Compare to Sign_TEE: same ctx semantics, HSM-held sk. Both
// produce wire bytes verifiable under the same PULG-framed group key.
//
// signCtx is the FIPS 204 ctx octet string (0..255 bytes). Pass nil
// (or the empty hex string "") to bind the empty ctx — semantically
// equivalent to Sign, but emitted via the single-party path.
// (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
// JSON-RPC 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.
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 server.go). The gate fires at entry:
// on a strict-PQ chain, the call is refused with
// ErrRefusedUnderStrictPQ (HTTP 503 + documented body); on any
// other profile, or with no resolver / no chain ID, the call 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.
//
// 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.
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
}
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).
func (s *pulsarScheme) signCtxInternal(p signCtxParams) (signResult, error) {
msg, err := hex.DecodeString(p.MessageHex)
if err != nil {
return signResult{}, fmt.Errorf("messageHex: %w", err)
@@ -362,23 +401,42 @@ func (s *pulsarScheme) Sign_Ctx(p signCtxParams) (signResult, error) {
s.mu.Lock()
sess, ok := s.sessions[p.PubKeyHex]
s.mu.Unlock()
if !ok {
s.mu.Unlock()
return signResult{}, fmt.Errorf("pulsar sign_ctx: unknown pubKeyHex (keygen first)")
}
if sess.dealerKey == nil {
// Defence in depth: every Keygen sets dealerKey. A nil here
// would mean session-map corruption — refuse rather than
// silently fall back.
return signResult{}, fmt.Errorf("pulsar sign_ctx: session missing dealerKey")
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)
}
params := pulsar.MustParamsFor(sess.setup.Mode)
// Deterministic (randomized=false) so the output is KAT-shaped
// and byte-stable across retries — mirrors Sign_TEE's
// SignDeterministic discipline.
sig, err := pulsar.Sign(params, sess.dealerKey, msg, signCtx, false, nil)
sig, err := pulsar.OrchestrateV03SignCtx(params, setup, signCtx, msg, sessionID,
quorum, quorumShares, evalPoints, sessionKeys, params.MaxRestart, rand.Reader)
if err != nil {
return signResult{}, fmt.Errorf("pulsar sign_ctx: %w", err)
}
@@ -386,7 +444,7 @@ func (s *pulsarScheme) Sign_Ctx(p signCtxParams) (signResult, error) {
// 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, sess.setup.Pub, msg, signCtx, sig); err != nil {
if err := pulsar.VerifyCtx(params, setup.Pub, msg, signCtx, sig); err != nil {
return signResult{}, fmt.Errorf("pulsar sign_ctx: produced signature failed self-verify (kernel bug): %w", err)
}
+79 -1
View File
@@ -4,6 +4,7 @@ package thresholdd
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -57,10 +58,22 @@ type signParams struct {
// `lux-evm-precompile-mldsa-v1` / `lux-evm-precompile-slhdsa-v1` to
// produce signatures that satisfy the on-chain EVM precompile's
// domain-separation contract.
//
// ChainID, when set, asserts the chain context the caller intends
// this signature to land on. The dispatcher consults the wired
// ChainProfileResolver to map ChainID → Profile; on a strict-PQ
// chain, the single-party dealer / single-validator shortcut path
// is refused (HTTP 503 + the documented body) until sister
// agents land pulsar v0.4's threshold ctx-bound path. Empty
// ChainID means "no chain context asserted"; the gate fails open
// (see profile.go::RefuseUnderStrictPQ). ChainID may also be
// supplied via the X-Chain-ID HTTP header; the JSON body field
// wins when both are set.
type signCtxParams struct {
MessageHex string `json:"messageHex"`
PubKeyHex string `json:"pubKeyHex"`
CtxHex string `json:"ctxHex"`
ChainID string `json:"chainID,omitempty"`
}
// signResult is the common shape for every <scheme>.sign response.
@@ -96,6 +109,18 @@ type ctxSigner interface {
Sign_Ctx(p signCtxParams) (signResult, error)
}
// profileAwareCtxSigner is the strict-PQ-aware variant of ctxSigner.
// Schemes that implement it run the strict-PQ gate
// (RefuseUnderStrictPQ) at entry against the supplied chainID and
// resolver before producing a signature. Pulsar and magnetar
// implement this; other schemes fall back to plain ctxSigner. The
// resolver is supplied by the dispatcher (see Server) so call sites
// never reach across the lock to read it. A nil resolver bypasses
// the gate (documented fail-OPEN — see profile.go).
type profileAwareCtxSigner interface {
Sign_Ctx_Profile(p signCtxParams, resolver ChainProfileResolver) (signResult, error)
}
// Server is the JSON-RPC dispatcher.
//
// Auth: a non-empty `authToken` gates every JSON-RPC request behind a
@@ -112,6 +137,13 @@ type Server struct {
mu sync.RWMutex
schemes map[string]scheme
authToken string
// chainProfileResolver is the optional chain-profile lookup the
// strict-PQ gate consults on Sign_Ctx. nil → gate fails open
// (documented in profile.go::RefuseUnderStrictPQ); production
// embedders SetChainProfileResolver at boot with an adapter
// over luxfi/consensus/config.
chainProfileResolver ChainProfileResolver
}
// NewServer builds the dispatcher with the wired schemes.
@@ -153,6 +185,17 @@ func (s *Server) SetAuthToken(token string) {
s.mu.Unlock()
}
// SetChainProfileResolver installs the chain-profile resolver the
// strict-PQ gate consults on Sign_Ctx. Passing nil clears the
// resolver (the gate then fails open). Production embedders wire an
// adapter over luxfi/consensus/config; the standalone thresholdd
// CLI leaves it nil for dev tooling.
func (s *Server) SetChainProfileResolver(r ChainProfileResolver) {
s.mu.Lock()
s.chainProfileResolver = r
s.mu.Unlock()
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -241,8 +284,43 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeError(w, req.ID, -32602, "invalid params: "+err.Error())
return
}
res, err := cs.Sign_Ctx(p)
// Plumb chain ID: JSON body wins; X-Chain-ID header
// supplies it when the body omits it. Empty means "no
// chain asserted" → gate falls through (see profile.go).
if p.ChainID == "" {
p.ChainID = r.Header.Get("X-Chain-ID")
}
// Profile-aware path: schemes that implement
// profileAwareCtxSigner consult the strict-PQ gate
// themselves at entry. Plain ctxSigner is the legacy path
// (gate not consulted). Snapshot the resolver under RLock
// so SetChainProfileResolver can swap atomically.
s.mu.RLock()
resolver := s.chainProfileResolver
s.mu.RUnlock()
var (
res signResult
err error
)
if pas, ok := sc.(profileAwareCtxSigner); ok {
res, err = pas.Sign_Ctx_Profile(p, resolver)
} else {
res, err = cs.Sign_Ctx(p)
}
if err != nil {
// Strict-PQ refusal: HTTP 503 + the documented body
// (see profile.go::strictPQRefusalBody). NOT a
// JSON-RPC error envelope — the documented contract
// says HTTP 503 because the dispatcher temporarily
// cannot produce a strict-PQ-compliant Sign_Ctx
// signature, which is a service-unavailable
// condition, not a request error.
if errors.Is(err, ErrRefusedUnderStrictPQ) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(strictPQRefusalBody))
return
}
writeError(w, req.ID, -32000, err.Error())
return
}
+573
View File
@@ -0,0 +1,573 @@
// SPDX-License-Identifier: BSD-3-Clause
package thresholdd
import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
)
// strict_pq_gate_test.go — race-clean tests for profile.go and the
// dispatcher's strict-PQ refusal behavior on Sign_Ctx.
//
// Coverage:
//
// - TestRefuseUnderStrictPQ_PolicyMatrix walks every input the gate
// function accepts and asserts the return value.
// - TestSignCtx_StrictPQ_RejectsDealerShortcut drives a full RPC
// round trip; the dispatcher MUST surface HTTP 503 + the
// documented body when the chain is on strict-PQ.
// - TestSignCtx_LegacyCompatProfile_AllowsDealerShortcut proves the
// same dispatcher with a legacy-compat chain returns a normal
// signature (HTTP 200 + signatureHex).
// - TestSignCtx_DispatcherDeclaresProfile demonstrates the chain
// profile is reachable via BOTH the JSON body ChainID field AND
// the X-Chain-ID HTTP header. Race-clean via concurrent invocations.
//
// All tests run with t.Parallel() and -race.
// staticChainResolver is a tiny test resolver with explicit per-chain
// profile mappings. Defaults to ProfileUnknown so unmapped chain IDs
// fail-OPEN (matches the documented behaviour).
type fakeChainResolver struct {
mu sync.RWMutex
mapping map[string]Profile
queryCnt atomic.Int64
}
func newFakeChainResolver(mapping map[string]Profile) *fakeChainResolver {
cp := make(map[string]Profile, len(mapping))
for k, v := range mapping {
cp[k] = v
}
return &fakeChainResolver{mapping: cp}
}
func (r *fakeChainResolver) ResolveChainProfile(chainID string) Profile {
r.queryCnt.Add(1)
r.mu.RLock()
defer r.mu.RUnlock()
if p, ok := r.mapping[chainID]; ok {
return p
}
return ProfileUnknown
}
// TestRefuseUnderStrictPQ_PolicyMatrix walks the gate's input space
// directly (no dispatcher in the loop). One row per documented
// branch — the gate is small enough that exhaustive coverage is
// cheap and prevents regressions when the body is edited.
func TestRefuseUnderStrictPQ_PolicyMatrix(t *testing.T) {
t.Parallel()
strictResolver := newFakeChainResolver(map[string]Profile{
"lux-mainnet": ProfileStrictPQ,
"lux-testnet": ProfileLegacyCompat,
})
tests := []struct {
name string
chainID string
resolver ChainProfileResolver
wantError bool
}{
{
name: "nil resolver passes (fail-OPEN)",
chainID: "lux-mainnet",
resolver: nil,
wantError: false,
},
{
name: "empty chainID passes (no chain asserted)",
chainID: "",
resolver: strictResolver,
wantError: false,
},
{
name: "strict-PQ chain refuses",
chainID: "lux-mainnet",
resolver: strictResolver,
wantError: true,
},
{
name: "legacy-compat chain passes",
chainID: "lux-testnet",
resolver: strictResolver,
wantError: false,
},
{
name: "unknown chain passes (fail-OPEN)",
chainID: "some-other-chain",
resolver: strictResolver,
wantError: false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := RefuseUnderStrictPQ(tc.chainID, "pulsar.sign_ctx", tc.resolver)
if tc.wantError {
if err == nil {
t.Fatalf("RefuseUnderStrictPQ: expected error, got nil")
}
if !errors.Is(err, ErrRefusedUnderStrictPQ) {
t.Fatalf("RefuseUnderStrictPQ: error %v does not wrap ErrRefusedUnderStrictPQ", err)
}
} else {
if err != nil {
t.Fatalf("RefuseUnderStrictPQ: unexpected error %v", err)
}
}
})
}
}
// TestIsStrictPQProfile_Direct mirrors the matrix above for the
// helper. Cheap parity check that IsStrictPQProfile and
// RefuseUnderStrictPQ agree on what "strict-PQ" means.
func TestIsStrictPQProfile_Direct(t *testing.T) {
t.Parallel()
resolver := newFakeChainResolver(map[string]Profile{
"lux-mainnet": ProfileStrictPQ,
"lux-testnet": ProfileLegacyCompat,
})
if !IsStrictPQProfile("lux-mainnet", resolver) {
t.Fatalf("IsStrictPQProfile(lux-mainnet) = false, want true")
}
if IsStrictPQProfile("lux-testnet", resolver) {
t.Fatalf("IsStrictPQProfile(lux-testnet) = true, want false")
}
if IsStrictPQProfile("unknown", resolver) {
t.Fatalf("IsStrictPQProfile(unknown) = true, want false (fail-OPEN)")
}
if IsStrictPQProfile("anything", nil) {
t.Fatalf("IsStrictPQProfile(_, nil) = true, want false (no resolver)")
}
}
// startTestServerWithResolver brings up the dispatcher with the
// supplied chain-profile resolver. The auth gate stays disabled
// for this test (loopback only — Server's auth token is for
// production embedders).
func startTestServerWithResolver(t *testing.T, resolver ChainProfileResolver) (*Server, string, func()) {
t.Helper()
srv, err := NewServer()
if err != nil {
t.Fatalf("build server: %v", err)
}
srv.SetChainProfileResolver(resolver)
ts := httptest.NewServer(srv)
if !strings.HasPrefix(ts.URL, "http://127.0.0.1:") && !strings.HasPrefix(ts.URL, "http://[::1]:") {
t.Fatalf("test server not on loopback: %s", ts.URL)
}
return srv, ts.URL, ts.Close
}
// postSignCtx hits <scheme>.sign_ctx with the supplied chain ID
// (passed in both the JSON body and via X-Chain-ID header — body
// wins by contract). Returns the raw HTTP response so tests can
// assert status + body precisely.
func postSignCtx(t *testing.T, url, scheme, msgHex, pkHex, ctxHex, chainID string) *http.Response {
t.Helper()
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": scheme + ".sign_ctx",
"params": map[string]any{
"messageHex": msgHex,
"pubKeyHex": pkHex,
"ctxHex": ctxHex,
"chainID": chainID,
},
})
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
t.Fatalf("build req: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if chainID != "" {
req.Header.Set("X-Chain-ID", chainID)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
return resp
}
// TestSignCtx_StrictPQ_RejectsDealerShortcut drives a full keygen +
// Sign_Ctx round trip against a strict-PQ-mapped chain ID. The
// dispatcher MUST return HTTP 503 + the documented body. This is
// the headline gate test — proves the strict-PQ refusal is
// load-bearing on the actual JSON-RPC surface, not just the unit
// function.
//
// Tests BOTH schemes (pulsar and magnetar) — the gate composes the
// same way on each, so any divergence in behavior is a bug.
//
// Each scheme runs in its own t.Run with its own server so the
// httptest cleanup order does not race with parallel subtests.
func TestSignCtx_StrictPQ_RejectsDealerShortcut(t *testing.T) {
t.Parallel()
type schemeCase struct {
name string
t, n int
msg string
ctxIn string
}
cases := []schemeCase{
{"pulsar", 2, 3, "pulsar strict-pq reject", "lux-evm-precompile-mldsa-v1"},
{"magnetar", 1, 1, "magnetar strict-pq reject", "lux-evm-precompile-slhdsa-v1"},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
resolver := newFakeChainResolver(map[string]Profile{
"lux-mainnet": ProfileStrictPQ,
})
_, url, stop := startTestServerWithResolver(t, resolver)
defer stop()
var kg keygenResult
rpcCall(t, url, c.name+".keygen", map[string]any{
"threshold": c.t,
"participants": c.n,
}, &kg)
if kg.PublicKey == "" {
t.Fatalf("%s.keygen: empty publicKey", c.name)
}
// Sign_Ctx with chainID = "lux-mainnet" (strict-PQ) —
// MUST refuse. HTTP 503 + documented body.
resp := postSignCtx(t, url, c.name,
hex.EncodeToString([]byte(c.msg)),
kg.PublicKey,
hex.EncodeToString([]byte(c.ctxIn)),
"lux-mainnet")
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("%s strict-PQ refusal: status=%d body=%s; want 503 + documented body",
c.name, resp.StatusCode, string(bodyBytes))
}
gotBody, _ := io.ReadAll(resp.Body)
if string(gotBody) != strictPQRefusalBody {
t.Fatalf("%s strict-PQ refusal body=%q want=%q",
c.name, string(gotBody), strictPQRefusalBody)
}
})
}
}
// TestSignCtx_LegacyCompatProfile_AllowsDealerShortcut proves the
// SAME dispatcher with the SAME schemes returns a normal HTTP 200
// + signatureHex when the chain is mapped to legacy-compat. Catches
// a regression where the gate accidentally trips on every request.
//
// Each scheme gets its own server (subtests t.Parallel, parent
// defer fires after subtests complete only if the parent does
// NOT also parallelize — we side-step by per-subtest setup).
func TestSignCtx_LegacyCompatProfile_AllowsDealerShortcut(t *testing.T) {
t.Parallel()
type schemeCase struct {
name string
t, n int
msg string
ctxIn string
}
cases := []schemeCase{
{"pulsar", 2, 3, "pulsar legacy-compat allow", "lux-evm-precompile-mldsa-v1"},
{"magnetar", 1, 1, "magnetar legacy-compat allow", "lux-evm-precompile-slhdsa-v1"},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
resolver := newFakeChainResolver(map[string]Profile{
"lux-testnet": ProfileLegacyCompat,
})
_, url, stop := startTestServerWithResolver(t, resolver)
defer stop()
var kg keygenResult
rpcCall(t, url, c.name+".keygen", map[string]any{
"threshold": c.t,
"participants": c.n,
}, &kg)
resp := postSignCtx(t, url, c.name,
hex.EncodeToString([]byte(c.msg)),
kg.PublicKey,
hex.EncodeToString([]byte(c.ctxIn)),
"lux-testnet")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("%s legacy-compat: status=%d body=%s; want 200 + signature",
c.name, resp.StatusCode, string(body))
}
var env struct {
Result *signResult `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
t.Fatalf("decode env: %v", err)
}
if env.Error != nil {
t.Fatalf("%s legacy-compat: rpc error %d: %s",
c.name, env.Error.Code, env.Error.Message)
}
if env.Result == nil || env.Result.SignatureHex == "" {
t.Fatalf("%s legacy-compat: empty signature", c.name)
}
})
}
}
// TestSignCtx_DispatcherDeclaresProfile proves the chain profile
// is reachable via BOTH the JSON body ChainID field AND the
// X-Chain-ID HTTP header, and that the gate trips identically on
// each.
//
// The outer test holds the server + pre-keygen state; subtests
// run sequentially (no t.Parallel) so the shared server stays
// alive for every subtest. Race-cleanliness is exercised by
// TestSignCtx_DispatcherDeclaresProfile_Race below which drives
// the resolver from concurrent goroutines.
func TestSignCtx_DispatcherDeclaresProfile(t *testing.T) {
t.Parallel()
resolver := newFakeChainResolver(map[string]Profile{
"strict-chain": ProfileStrictPQ,
"compat-chain": ProfileLegacyCompat,
})
_, url, stop := startTestServerWithResolver(t, resolver)
defer stop()
// Pre-keygen one session (pulsar 2-of-3 is the canonical smoke
// shape; the gate logic is identical for magnetar so testing
// pulsar here is sufficient).
var kg keygenResult
rpcCall(t, url, "pulsar.keygen", map[string]any{
"threshold": 2,
"participants": 3,
}, &kg)
msg := hex.EncodeToString([]byte("dispatcher-declares-profile"))
ctxHex := hex.EncodeToString([]byte("lux-evm-precompile-mldsa-v1"))
tests := []struct {
name string
chainID string
viaHeader bool
viaBody bool
wantStatus int
}{
{"strict via header only", "strict-chain", true, false, http.StatusServiceUnavailable},
{"strict via body only", "strict-chain", false, true, http.StatusServiceUnavailable},
{"strict via both header+body", "strict-chain", true, true, http.StatusServiceUnavailable},
{"compat via header only", "compat-chain", true, false, http.StatusOK},
{"compat via body only", "compat-chain", false, true, http.StatusOK},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
body := map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "pulsar.sign_ctx",
"params": map[string]any{
"messageHex": msg,
"pubKeyHex": kg.PublicKey,
"ctxHex": ctxHex,
},
}
if tc.viaBody {
body["params"].(map[string]any)["chainID"] = tc.chainID
}
bs, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(bs))
if err != nil {
t.Fatalf("build req: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if tc.viaHeader {
req.Header.Set("X-Chain-ID", tc.chainID)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.wantStatus {
bs, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s; want %d", resp.StatusCode, string(bs), tc.wantStatus)
}
})
}
}
// TestSignCtx_DispatcherDeclaresProfile_Race exercises the gate
// from many concurrent goroutines so any data race on the
// resolver, the server's chainProfileResolver field, or the
// per-request body+header plumbing is caught under `go test -race`.
func TestSignCtx_DispatcherDeclaresProfile_Race(t *testing.T) {
t.Parallel()
resolver := newFakeChainResolver(map[string]Profile{
"strict-chain": ProfileStrictPQ,
"compat-chain": ProfileLegacyCompat,
})
_, url, stop := startTestServerWithResolver(t, resolver)
defer stop()
var kg keygenResult
rpcCall(t, url, "pulsar.keygen", map[string]any{
"threshold": 2,
"participants": 3,
}, &kg)
msg := hex.EncodeToString([]byte("dispatcher-race"))
ctxHex := hex.EncodeToString([]byte("lux-evm-precompile-mldsa-v1"))
const workers = 16
var wg sync.WaitGroup
wg.Add(workers)
errCh := make(chan error, workers)
for i := 0; i < workers; i++ {
go func(idx int) {
defer wg.Done()
// Alternate strict and compat chains so workers race
// on different profile paths.
chain := "strict-chain"
wantStatus := http.StatusServiceUnavailable
if idx%2 == 0 {
chain = "compat-chain"
wantStatus = http.StatusOK
}
resp := postSignCtx(t, url, "pulsar", msg, kg.PublicKey, ctxHex, chain)
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode != wantStatus {
errCh <- errors.New("worker " + chain + ": unexpected status")
}
}(i)
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Fatalf("%v", err)
}
}
// TestSignCtx_NoResolverWired_FailsOpen ensures that the default
// dispatcher (no SetChainProfileResolver call) does NOT refuse on a
// "lux-mainnet" chain ID. Documents the fail-OPEN behaviour: an
// embedder that forgets to wire a resolver gets the legacy
// behaviour, not a hard refusal. (Production embedders pair the
// outer admission gate with an explicit StaticChainProfileResolver
// default of ProfileStrictPQ if they want fail-CLOSED.)
func TestSignCtx_NoResolverWired_FailsOpen(t *testing.T) {
t.Parallel()
// Build server WITHOUT calling SetChainProfileResolver.
srv, err := NewServer()
if err != nil {
t.Fatalf("build server: %v", err)
}
ts := httptest.NewServer(srv)
defer ts.Close()
var kg keygenResult
rpcCall(t, ts.URL, "pulsar.keygen", map[string]any{
"threshold": 2,
"participants": 3,
}, &kg)
resp := postSignCtx(t, ts.URL, "pulsar",
hex.EncodeToString([]byte("no-resolver-wired")),
kg.PublicKey,
hex.EncodeToString([]byte("lux-evm-precompile-mldsa-v1")),
"lux-mainnet") // would refuse if a strict-PQ resolver were wired
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bs, _ := io.ReadAll(resp.Body)
t.Fatalf("no-resolver fail-OPEN broken: status=%d body=%s; want 200",
resp.StatusCode, string(bs))
}
}
// TestSignCtx_NoChainID_FailsOpen ensures that omitting the chain
// ID entirely lets the dispatcher fall through to the legacy path
// even when a strict-PQ resolver is wired. Documents the
// "no chain asserted, no posture to enforce" branch of the gate.
func TestSignCtx_NoChainID_FailsOpen(t *testing.T) {
t.Parallel()
resolver := newFakeChainResolver(map[string]Profile{
"lux-mainnet": ProfileStrictPQ,
})
_, url, stop := startTestServerWithResolver(t, resolver)
defer stop()
var kg keygenResult
rpcCall(t, url, "pulsar.keygen", map[string]any{
"threshold": 2,
"participants": 3,
}, &kg)
// Empty chainID → gate falls through.
resp := postSignCtx(t, url, "pulsar",
hex.EncodeToString([]byte("no-chain-id")),
kg.PublicKey,
hex.EncodeToString([]byte("lux-evm-precompile-mldsa-v1")),
"")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bs, _ := io.ReadAll(resp.Body)
t.Fatalf("empty chainID fail-OPEN broken: status=%d body=%s; want 200",
resp.StatusCode, string(bs))
}
}
// TestNewStaticChainProfileResolver_DefaultBehaviour exercises the
// static resolver constructor — sanity check that the constructor
// returns a resolver whose ResolveChainProfile honors the supplied
// map and falls back to def.
func TestNewStaticChainProfileResolver_DefaultBehaviour(t *testing.T) {
t.Parallel()
r := NewStaticChainProfileResolver(ProfileLegacyCompat, map[string]Profile{
"strict": ProfileStrictPQ,
"unset": ProfileUnknown,
})
if got := r.ResolveChainProfile("strict"); got != ProfileStrictPQ {
t.Fatalf("strict: got %s want strict-PQ", got)
}
if got := r.ResolveChainProfile("unset"); got != ProfileUnknown {
t.Fatalf("unset: got %s want unknown", got)
}
if got := r.ResolveChainProfile("absent"); got != ProfileLegacyCompat {
t.Fatalf("absent: got %s want legacy-compat (default)", got)
}
}
+7 -1
View File
@@ -429,8 +429,14 @@ func TestMain(m *testing.M) {
_ = os.Setenv("MPC_LOCAL_APPROVAL", "true")
// Each subtest also enforces protocol-level timeouts via runner.go.
// 30 minutes accommodates `-race -count=N` under the v1.1.0
// algebraic-aggregate Sign_Ctx path (which runs the full quorum
// FIPS 204 rejection-restart loop where the v1.0.x dealer-shortcut
// ran a single-party SignTo). Race instrumentation adds ~3-5x to
// per-test cost; the rejection-restart loop can stack on top.
// Beyond 30m something IS stuck.
go func() {
time.Sleep(10 * time.Minute)
time.Sleep(30 * time.Minute)
panic("thresholdd_test: global timeout — protocol stuck")
}()
m.Run()