mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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).
132 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|