mirror of
https://github.com/luxfi/threshold.git
synced 2026-07-27 04:01:59 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user