Files
mpc/cmd/mpcd/chain_profile_test.go
zeekay 4508cb3f2a security(mpcd): wire fail-closed strict-PQ chain-profile gate — Red M-2
The embedded threshold dispatcher's Sign_Ctx_Profile consults a
thresholdd.ChainProfileResolver before allowing the single-party dealer
shortcut on ctx-bound signing (pulsar/magnetar sign_ctx). mpcd built the
ZapServer with NO resolver, so the gate fell to its documented fail-OPEN
branch — every chainID, strict-PQ chains included, slipped the gate.

Wire the node's chain-profile posture into the dispatcher BEFORE Start:
  - --chain-profile (MPC_CHAIN_PROFILE), default strict-pq → fail-closed.
    Any non-empty chainID not allow-listed resolves to ProfileStrictPQ and
    the dealer shortcut is REFUSED.
  - --legacy-chains (MPC_LEGACY_CHAINS): explicit allow-list of chainIDs
    permitted the shortcut (dev / non-PQ chains co-served by a strict node).

Resolver is the canonical thresholdd.NewStaticChainProfileResolver — no
bespoke type, no luxfi/consensus/config dependency.

Residual: thresholdd.RefuseUnderStrictPQ short-circuits empty chainID to
PASS before consulting the resolver; that short-circuit lives inside the
threshold gate (threshold owns the listener, read-only here). The resolver
wired here is already fail-closed for "" (ResolveChainProfile("") ==
ProfileStrictPQ), so the residual closes the instant the threshold gate
routes "" through the resolver — a one-line change in
threshold/pkg/thresholdd/profile.go (drop `|| chainID == ""`).

Tests: TestStrictPQGate_FailClosed / _LegacyDefaultPermissive drive the
real gate proving refuse-by-default + allow-list opt-out + empty/unknown
fail-closed at the resolver boundary.

Also drops the stale "bls" scheme from the dispatcher start log (BLS
custody was ripped in H-2).
2026-06-26 21:38:59 -07:00

132 lines
4.9 KiB
Go

// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"errors"
"testing"
"github.com/luxfi/threshold/pkg/thresholdd"
)
func TestChainProfileFromString(t *testing.T) {
cases := []struct {
in string
want thresholdd.Profile
wantErr bool
}{
{"", thresholdd.ProfileStrictPQ, false}, // unset → fail-secure default
{"strict-pq", thresholdd.ProfileStrictPQ, false}, //
{"STRICT-PQ", thresholdd.ProfileStrictPQ, false}, // case-insensitive
{" strict ", thresholdd.ProfileStrictPQ, false}, // trimmed alias
{"pq", thresholdd.ProfileStrictPQ, false}, // alias
{"fips", thresholdd.ProfileStrictPQ, false}, // FIPS is IsPQ → strict
{"legacy-compat", thresholdd.ProfileLegacyCompat, false},
{"legacy", thresholdd.ProfileLegacyCompat, false},
{"permissive", thresholdd.ProfileLegacyCompat, false},
{"unknown", thresholdd.ProfileUnknown, false},
{"open", thresholdd.ProfileUnknown, false},
{"garbage", thresholdd.ProfileUnknown, true},
}
for _, tc := range cases {
got, err := chainProfileFromString(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("chainProfileFromString(%q): want error, got nil", tc.in)
}
continue
}
if err != nil {
t.Errorf("chainProfileFromString(%q): unexpected error %v", tc.in, err)
}
if got != tc.want {
t.Errorf("chainProfileFromString(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestSplitCSV(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{" ", nil},
{"96369", []string{"96369"}},
{"96369,96368", []string{"96369", "96368"}},
{" 96369 , 96368 ", []string{"96369", "96368"}},
{"96369 96368", []string{"96369", "96368"}},
{",,96369,,", []string{"96369"}},
}
for _, tc := range cases {
got := splitCSV(tc.in)
if len(got) != len(tc.want) {
t.Fatalf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("splitCSV(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i])
}
}
}
}
// TestStrictPQGate_FailClosed drives the real threshold gate
// (thresholdd.RefuseUnderStrictPQ) with the resolver mpcd wires, proving
// the M-2 fix: a strict-PQ node refuses the dealer shortcut for every
// non-empty chainID it does not explicitly allow-list.
func TestStrictPQGate_FailClosed(t *testing.T) {
def, err := chainProfileFromString("strict-pq")
if err != nil {
t.Fatalf("setup: %v", err)
}
resolver := buildChainProfileResolver(def, []string{"96369"}) // 96369 = legacy allow-listed
// 1. nil resolver (pre-fix wiring) → gate fails OPEN. This is the M-2
// hole the wiring closes; asserted here so a regression that drops
// the SetChainProfileResolver call is caught by the contrast below.
if err := thresholdd.RefuseUnderStrictPQ("99999", "pulsar.sign_ctx", nil); err != nil {
t.Fatalf("nil-resolver gate must pass (documented fail-open), got %v", err)
}
// 2. unknown non-empty chain on a strict-PQ node → REFUSED (fail-closed).
if err := thresholdd.RefuseUnderStrictPQ("99999", "pulsar.sign_ctx", resolver); err == nil {
t.Error("strict-PQ node must REFUSE sign_ctx for unknown chain 99999, gate passed")
} else if !errors.Is(err, thresholdd.ErrRefusedUnderStrictPQ) {
t.Errorf("want ErrRefusedUnderStrictPQ, got %v", err)
}
// 3. explicitly allow-listed legacy chain → PASS (operator opt-out honoured).
if err := thresholdd.RefuseUnderStrictPQ("96369", "pulsar.sign_ctx", resolver); err != nil {
t.Errorf("allow-listed legacy chain 96369 must pass, got %v", err)
}
// 4. The resolver itself is fail-closed for the EMPTY chainID: once the
// threshold gate routes "" through the resolver (the reported one-line
// change) the context-less sign_ctx is refused. Proven at the resolver
// boundary because RefuseUnderStrictPQ today still short-circuits ""
// to PASS before consulting the resolver (threshold-owned residual).
if got := resolver.ResolveChainProfile(""); got != thresholdd.ProfileStrictPQ {
t.Errorf("resolver must map empty chainID → ProfileStrictPQ (fail-closed), got %v", got)
}
if got := resolver.ResolveChainProfile("unmapped"); got != thresholdd.ProfileStrictPQ {
t.Errorf("resolver must map unknown chainID → ProfileStrictPQ (fail-closed), got %v", got)
}
}
// TestStrictPQGate_LegacyDefaultPermissive proves a deliberately
// downgraded node (legacy-compat default) lets ctx-bound signing through
// — the documented dev / non-PQ posture, opt-in only.
func TestStrictPQGate_LegacyDefaultPermissive(t *testing.T) {
def, err := chainProfileFromString("legacy-compat")
if err != nil {
t.Fatalf("setup: %v", err)
}
resolver := buildChainProfileResolver(def, nil)
if err := thresholdd.RefuseUnderStrictPQ("99999", "magnetar.sign_ctx", resolver); err != nil {
t.Errorf("legacy-compat node must pass sign_ctx, got %v", err)
}
if got := resolver.ResolveChainProfile(""); got != thresholdd.ProfileLegacyCompat {
t.Errorf("legacy default must map empty chainID → ProfileLegacyCompat, got %v", got)
}
}