Files
zeekay 414daf6d87 feat(backend): App(Wallet) MPC custody server (Go, no plaintext keys)
Add apps/backend — the wallet's custody server. The load-bearing Front-E gap
(repo was FE-only) is now closed: a wallet account model backed by MPC custody
where the private key is split t-of-n across the MPC nodes (lux →
mpc.lux.network) and NEVER assembled — this service stores only public keys +
addresses.

- internal/iam: lux.id OIDC bearer verification via JWKS (discovery + RS256/
  ES256, alg-confusion-safe — HMAC/none refused). Org from the 'owner' claim.
  golang-jwt/v5 (matches the lux ecosystem), stdlib JWKS fetcher (no extra dep).
- internal/custody: the Custodian port (a value the wallet owns) + HTTP adapter
  to lux/mpc's verified /keygen + /sign wire. Mirrors mpc client.Threshold's
  security contract (idempotency required, org-scoped). PQ-ready: mldsa65 is a
  first-class scheme (the PQ primitives stay in pkgs/wallet/.../pq + consensus —
  not duplicated). No in-process key fallback — unconfigured MPC is an error.
- internal/store: wallet↔org↔addresses (no key field). Org-scoped reads — a
  cross-org get is ErrNotFound (no existence disclosure).
- internal/api: /v1/* (no /api/ prefix). IAM-gated, org-scoped. POST/GET
  /v1/wallets, GET /v1/wallets/{id}, POST /v1/wallets/{id}/sign, GET /v1/health.
- cmd/wallet-backend: env config (white-label per tenant: IAM_ISSUER/AUDIENCE,
  MPC_ENDPOINT — shared hanzo-mpc or BYOMPC), JSON logs, graceful shutdown.

Deploy (native CI + operator, NO GHA): build-only .platform.yml → ghcr.io/
luxfi/wallet-backend on lux-build pools; lux operator rolls
apps/backend/k8s/wallet-backend.yaml (lux.cloud/v1 Service + KMSSecret for
MPC_SERVICE_TOKEN), ingress wallet-api.lux.network.

17 tests green (go test ./...): IAM (valid/no-org/wrong-aud/expired/alg-
confusion), custody (no-key-leak, idempotency-required, PQ-accepted, no-
fallback), API (auth-gate, org-isolation cross-org→404, create→sign), + a full
E2E through the real adapter→mock-MPC→real-server. Binary boots, /v1/health ok,
/v1/wallets 401 unauth. LLM.md captures the App(Wallet) spec for the unified
operator's App Kind.
2026-06-23 15:07:40 -07:00

139 lines
4.0 KiB
Go

package custody
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// mockMPC stands up a fake lux/mpc with the verified /keygen + /sign wire.
func mockMPC(t *testing.T) (*httptest.Server, *[]string) {
t.Helper()
var seenAuth []string
mux := http.NewServeMux()
mux.HandleFunc("/keygen", func(w http.ResponseWriter, r *http.Request) {
seenAuth = append(seenAuth, r.Header.Get("Authorization"))
var req struct {
OrgID string `json:"org_id"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.OrgID == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "org_id is required"})
return
}
json.NewEncoder(w).Encode(map[string]any{
"wallet_id": "wallet-abc",
"result_type": "success",
"ecdsa_pub_key": "04aabb",
"eddsa_pub_key": "ccdd",
"evm_address": "0x1111111111111111111111111111111111111111",
"btc_address": "bc1qexample",
"sol_address": "So1example",
})
})
mux.HandleFunc("/sign", func(w http.ResponseWriter, r *http.Request) {
var req signReq
json.NewDecoder(r.Body).Decode(&req)
if req.IdempotencyKey == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "idempotency required"})
return
}
json.NewEncoder(w).Encode(map[string]any{
"session_id": "sess-1",
"signature": "0xdeadbeef",
"status": "signed",
})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &seenAuth
}
func newCustodian(t *testing.T, endpoint, token string) Custodian {
t.Helper()
c, err := NewMPC(MPCConfig{Endpoint: endpoint, ServiceToken: token})
if err != nil {
t.Fatalf("NewMPC: %v", err)
}
return c
}
func TestNewMPC_NoEndpoint_NoInProcessFallback(t *testing.T) {
// There is no plaintext-key fallback — an unconfigured endpoint is an error.
if _, err := NewMPC(MPCConfig{Endpoint: " "}); err == nil {
t.Fatal("expected error for empty endpoint (no in-process custody)")
}
}
func TestCreateWallet_ReturnsAddresses_NoPrivateKey(t *testing.T) {
srv, auth := mockMPC(t)
c := newCustodian(t, srv.URL, "svc-token")
w, err := c.CreateWallet(context.Background(), "acme")
if err != nil {
t.Fatalf("CreateWallet: %v", err)
}
if w.WalletID != "wallet-abc" {
t.Errorf("walletId = %q", w.WalletID)
}
if w.Addresses["evm"] == "" || w.Addresses["btc"] == "" || w.Addresses["sol"] == "" {
t.Errorf("missing addresses: %+v", w.Addresses)
}
// The service token must be forwarded to MPC.
if len(*auth) == 0 || (*auth)[0] != "Bearer svc-token" {
t.Errorf("auth forwarded = %v", *auth)
}
// No private key anywhere in the serialized wallet.
b, _ := json.Marshal(w)
for _, banned := range []string{"private", "secret", "privKey", "seed", "mnemonic"} {
if strings.Contains(strings.ToLower(string(b)), banned) {
t.Errorf("wallet JSON leaked %q: %s", banned, b)
}
}
}
func TestSign_RequiresIdempotencyKey(t *testing.T) {
srv, _ := mockMPC(t)
c := newCustodian(t, srv.URL, "")
_, err := c.Sign(context.Background(), "acme", SignRequest{
WalletID: "wallet-abc", Scheme: SchemeSecp256k1, PayloadHash: "0xab",
})
if err != ErrIdempotencyKeyRequired {
t.Errorf("err = %v, want ErrIdempotencyKeyRequired", err)
}
}
func TestSign_RejectsUnknownScheme(t *testing.T) {
srv, _ := mockMPC(t)
c := newCustodian(t, srv.URL, "")
_, err := c.Sign(context.Background(), "acme", SignRequest{
WalletID: "w", Scheme: "rsa-2048", PayloadHash: "0xab", IdempotencyKey: "k1",
})
if err == nil || !strings.Contains(err.Error(), "unsupported scheme") {
t.Errorf("err = %v, want unsupported scheme", err)
}
}
func TestSign_PQScheme_Accepted(t *testing.T) {
srv, _ := mockMPC(t)
c := newCustodian(t, srv.URL, "")
res, err := c.Sign(context.Background(), "acme", SignRequest{
WalletID: "wallet-abc", Scheme: SchemeMLDSA65, ChainID: 96369,
PayloadHash: "0xab", IdempotencyKey: "k1",
})
if err != nil {
t.Fatalf("PQ sign: %v", err)
}
if res.Signature != "0xdeadbeef" {
t.Errorf("signature = %q", res.Signature)
}
}