mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
Red round 5 finding B1. The embedded dispatcher on --threshold-listen
was an anonymous local signing oracle:
1. Any process on the host could POST `{"method":"frost.sign",...}`
and obtain signatures with no auth gate (only the API on :9800
enforced MPC_INTERNAL_API_KEY).
2. An operator typo `MPC_THRESHOLD_LISTEN=0.0.0.0:7300` exposed it
cluster-wide.
3. Any SSRF inside mpcd pivoted into the dispatcher.
Fix mirrors the existing internal-API auth pattern (cmd/mpcd/main.go
line ~456):
- Auth token: read MPC_THRESHOLD_AUTH_TOKEN; if unset, derive a
deterministic per-cluster token from the node Ed25519 seed
(SHA-256(seed || "mpc-threshold-rpc") hex). All nodes share the
same key without extra config; log warns when derivation is used.
Server.SetAuthToken installs the gate.
- Loopback bind: refuse non-loopback addr unless
MPC_THRESHOLD_ALLOW_REMOTE=1 — operators must explicitly
acknowledge the exposure. Bare `:port` (= 0.0.0.0) is NOT loopback.
Also picks up luxfi/threshold v1.6.10 which removes the pulsar/corona
in-memory-token surface (Red HIGH B2) — pulsar.*/corona.* now return a
typed "not yet implemented" error.
Tests:
- TestIsThresholdLoopback locks the loopback predicate (8 cases:
127.0.0.1, [::1], localhost OK; 0.0.0.0, :port, RFC1918 refused).
- TestEmbeddedDispatcher_AuthGate proves 401 on missing/wrong token,
200 on valid token through the dispatcher mpcd installs.
4 tests pass in cmd/mpcd (was 2, +2).
254 lines
7.3 KiB
Go
254 lines
7.3 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Verifies the embedded threshold dispatcher (luxfi/threshold/pkg/thresholdd)
|
|
// is reachable from mpcd's process and answers a full keygen → sign → verify
|
|
// round-trip with at least one production scheme (BLS — fast keygen, no
|
|
// upstream flakiness).
|
|
//
|
|
// This is the smallest possible test that proves the consolidation is
|
|
// real: mpcd embeds the same package the standalone thresholdd CLI uses,
|
|
// the wire shape matches what teleport/mpc/src/signers/rpc.ts speaks,
|
|
// and the dispatcher refuses unknown schemes with -32601.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/luxfi/threshold/pkg/thresholdd"
|
|
)
|
|
|
|
// rpcCall posts a JSON-RPC 2.0 request against the embedded dispatcher
|
|
// and unmarshals the result envelope. Mirrors the wire used by the
|
|
// teleport mpc bus (signers/rpc.ts).
|
|
func rpcCall(t *testing.T, url, method string, params any) (json.RawMessage, *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}) {
|
|
t.Helper()
|
|
body, err := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": method,
|
|
"params": params,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status %d", resp.StatusCode)
|
|
}
|
|
var env struct {
|
|
Result json.RawMessage `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: %v", err)
|
|
}
|
|
return env.Result, env.Error
|
|
}
|
|
|
|
// TestEmbeddedDispatcher_BLSRoundTrip verifies the dispatcher mpcd
|
|
// embeds answers a real BLS keygen → sign → verify round-trip on the
|
|
// same wire teleport's PulsarSigner/CoronaSigner/Cggmp21Signer use.
|
|
func TestEmbeddedDispatcher_BLSRoundTrip(t *testing.T) {
|
|
srv, err := thresholdd.NewServer()
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
ts := httptest.NewServer(srv)
|
|
defer ts.Close()
|
|
|
|
// keygen
|
|
res, rpcErr := rpcCall(t, ts.URL, "bls.keygen", map[string]any{
|
|
"threshold": 2,
|
|
"participants": 3,
|
|
})
|
|
if rpcErr != nil {
|
|
t.Fatalf("bls.keygen error %d: %s", rpcErr.Code, rpcErr.Message)
|
|
}
|
|
var kg struct {
|
|
PublicKey string `json:"publicKey"`
|
|
Shares []string `json:"shares"`
|
|
}
|
|
if err := json.Unmarshal(res, &kg); err != nil {
|
|
t.Fatalf("unmarshal keygen: %v", err)
|
|
}
|
|
if kg.PublicKey == "" {
|
|
t.Fatalf("empty publicKey")
|
|
}
|
|
if len(kg.Shares) != 3 {
|
|
t.Fatalf("shares=%d want=3", len(kg.Shares))
|
|
}
|
|
|
|
// sign
|
|
msg := hex.EncodeToString([]byte("mpcd embedded dispatch smoke test"))
|
|
res, rpcErr = rpcCall(t, ts.URL, "bls.sign", map[string]any{
|
|
"messageHex": msg,
|
|
"pubKeyHex": kg.PublicKey,
|
|
})
|
|
if rpcErr != nil {
|
|
t.Fatalf("bls.sign error %d: %s", rpcErr.Code, rpcErr.Message)
|
|
}
|
|
var sg struct {
|
|
SignatureHex string `json:"signatureHex"`
|
|
}
|
|
if err := json.Unmarshal(res, &sg); err != nil {
|
|
t.Fatalf("unmarshal sign: %v", err)
|
|
}
|
|
if sg.SignatureHex == "" {
|
|
t.Fatalf("empty signatureHex")
|
|
}
|
|
|
|
// verify
|
|
res, rpcErr = rpcCall(t, ts.URL, "bls.verify", map[string]any{
|
|
"messageHex": msg,
|
|
"signatureHex": sg.SignatureHex,
|
|
"pubKeyHex": kg.PublicKey,
|
|
})
|
|
if rpcErr != nil {
|
|
t.Fatalf("bls.verify error %d: %s", rpcErr.Code, rpcErr.Message)
|
|
}
|
|
var vr struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
if err := json.Unmarshal(res, &vr); err != nil {
|
|
t.Fatalf("unmarshal verify: %v", err)
|
|
}
|
|
if !vr.OK {
|
|
t.Fatalf("verify: round-trip signature failed under embedded dispatcher")
|
|
}
|
|
|
|
// forgery rejection — different message under same signature must fail
|
|
wrong := hex.EncodeToString([]byte("forged"))
|
|
res, rpcErr = rpcCall(t, ts.URL, "bls.verify", map[string]any{
|
|
"messageHex": wrong,
|
|
"signatureHex": sg.SignatureHex,
|
|
"pubKeyHex": kg.PublicKey,
|
|
})
|
|
if rpcErr != nil {
|
|
t.Fatalf("bls.verify(wrong) error %d: %s", rpcErr.Code, rpcErr.Message)
|
|
}
|
|
_ = json.Unmarshal(res, &vr)
|
|
if vr.OK {
|
|
t.Fatalf("verify: forgery accepted (different message)")
|
|
}
|
|
}
|
|
|
|
// TestIsThresholdLoopback locks the loopback policy that gates the
|
|
// embedded dispatcher bind (Red HIGH B1). Bare `:port` is NOT loopback
|
|
// — operators must spell out the host so an accidental
|
|
// `--threshold-listen :7300` doesn't expose the dispatcher cluster-wide.
|
|
func TestIsThresholdLoopback(t *testing.T) {
|
|
cases := []struct {
|
|
addr string
|
|
want bool
|
|
}{
|
|
{"127.0.0.1:7300", true},
|
|
{"127.0.0.99:7300", true},
|
|
{"[::1]:7300", true},
|
|
{"localhost:7300", true},
|
|
{"0.0.0.0:7300", false},
|
|
{":7300", false},
|
|
{"10.0.0.1:7300", false},
|
|
{"[2001:db8::1]:7300", false},
|
|
{"not-an-addr", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := isThresholdLoopback(tc.addr); got != tc.want {
|
|
t.Errorf("isThresholdLoopback(%q) = %v, want %v", tc.addr, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEmbeddedDispatcher_AuthGate proves that when mpcd installs an
|
|
// auth token on the dispatcher, missing/bad bearer headers receive
|
|
// 401 and the valid header passes through.
|
|
func TestEmbeddedDispatcher_AuthGate(t *testing.T) {
|
|
srv, err := thresholdd.NewServer()
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
srv.SetAuthToken("cluster-token")
|
|
ts := httptest.NewServer(srv)
|
|
defer ts.Close()
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0", "id": 1, "method": "bls.keygen",
|
|
"params": map[string]any{"threshold": 2, "participants": 3},
|
|
})
|
|
|
|
// Missing auth → 401.
|
|
resp, err := http.Post(ts.URL, "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
resp.Body.Close()
|
|
t.Fatalf("missing-token: got %d, want 401", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Wrong token → 401.
|
|
req, _ := http.NewRequest("POST", ts.URL, bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer wrong")
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do wrong: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
resp.Body.Close()
|
|
t.Fatalf("wrong-token: got %d, want 401", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Valid token → 200.
|
|
req2, _ := http.NewRequest("POST", ts.URL, bytes.NewReader(body))
|
|
req2.Header.Set("Authorization", "Bearer cluster-token")
|
|
resp, err = http.DefaultClient.Do(req2)
|
|
if err != nil {
|
|
t.Fatalf("do valid: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
t.Fatalf("valid-token: got %d, want 200", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// TestEmbeddedDispatcher_UnknownScheme verifies the dispatcher returns
|
|
// a JSON-RPC -32601 method-not-found for an unrecognized scheme — the
|
|
// teleport bus relies on this contract to route fall-back signers.
|
|
func TestEmbeddedDispatcher_UnknownScheme(t *testing.T) {
|
|
srv, err := thresholdd.NewServer()
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
ts := httptest.NewServer(srv)
|
|
defer ts.Close()
|
|
|
|
_, rpcErr := rpcCall(t, ts.URL, "bogus.keygen", map[string]any{
|
|
"threshold": 1,
|
|
"participants": 1,
|
|
})
|
|
if rpcErr == nil {
|
|
t.Fatalf("expected JSON-RPC error for bogus.keygen, got success")
|
|
}
|
|
if rpcErr.Code != -32601 {
|
|
t.Fatalf("expected -32601 method-not-found, got code=%d msg=%q", rpcErr.Code, rpcErr.Message)
|
|
}
|
|
}
|