mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +00:00
test(backend): LIVE backend↔MPC custody e2e (not mock)
The not-mock counterpart to internal/api/e2e_test.go (which wires the real
custody adapter to a MOCK lux/mpc). This drives the wallet backend's /v1 API
against a REAL 3-node mpcd ensemble (consensus mode, the production code path)
over the live HTTP /keygen + /sign endpoints.
Path (a) full-OIDC: a local RSA-signed OIDC issuer + the real iam.Verifier
(standard discovery + JWKS + RS256 verify) + real org-scoping (owner claim) +
the real api.Server /v1 handlers + custody.NewMPC pointed at live node0. Proves
the full chain: lux.id verify → org scope → LIVE MPC threshold keygen + sign,
with no plaintext key in any backend response or MPC node log.
Exercises POST /v1/wallets (live keygen → real evm address), GET /v1/wallets,
POST /v1/wallets/{id}/sign (live threshold signature), backend-level idempotency
(same key → same signature), and the missing-idempotencyKey → 400 boundary.
//go:build integration. Builds mpcd from the lux/mpc repo (LUX_MPC_REPO, default
/Users/z/work/lux/mpc); skips cleanly if that repo is absent. Reuses the OIDC
helpers (newIDP/tokenForOrg/do) from api_test.go; stdlib-only ids (no new dep).
Run: go test -tags integration ./internal/api/ -run TestBackendLiveMPC -v
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build integration
|
||||
|
||||
// Package api, file live_mpc_e2e_test.go.
|
||||
//
|
||||
// LIVE backend ↔ MPC custody proof. This is the not-mock counterpart to
|
||||
// e2e_test.go (TestE2E_RealCustodyAdapter_CreateThenSign), which wires the real
|
||||
// custody adapter to a MOCK lux/mpc. Here the adapter talks to a REAL 3-node
|
||||
// mpcd ensemble (consensus mode — the production code path) over the real HTTP
|
||||
// /keygen + /sign endpoints. The full chain is exercised:
|
||||
//
|
||||
// lux.id OIDC verify (real iam.Verifier against a local issuer)
|
||||
// → org scope (owner claim)
|
||||
// → api.Server /v1 handlers (the real server)
|
||||
// → custody.NewMPC adapter (the real adapter)
|
||||
// → LIVE mpcd 3-node 2-of-3 threshold keygen + sign
|
||||
//
|
||||
// Path (a) full-OIDC is used (not the in-process handler shortcut): a local
|
||||
// RSA-signed OIDC issuer + a real iam.Verifier doing standard discovery + JWKS,
|
||||
// so the IAM verification, org-scoping, and MPC custody are all genuinely
|
||||
// exercised. The OIDC helpers (newIDP, tokenForOrg, do) are shared with
|
||||
// api_test.go.
|
||||
//
|
||||
// The mpcd binary is built from the lux/mpc repo (located via LUX_MPC_REPO,
|
||||
// default /Users/z/work/lux/mpc). If that repo is not present the test skips
|
||||
// with a clear message rather than failing — it is a cross-repo live proof.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// cd /Users/z/work/lux/wallet/apps/backend && \
|
||||
// go test -tags integration ./internal/api/ -run TestBackendLiveMPC -count=1 -timeout 300s -v
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/wallet/backend/internal/custody"
|
||||
"github.com/luxfi/wallet/backend/internal/iam"
|
||||
"github.com/luxfi/wallet/backend/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
liveInternalAPIKey = "backend-live-e2e-api-key"
|
||||
liveZapDBPassword = "backend-live-e2e-zapdb-pw"
|
||||
)
|
||||
|
||||
// mpcRepo returns the path to the lux/mpc repo, or "" if it cannot be found.
|
||||
func mpcRepo() string {
|
||||
if p := os.Getenv("LUX_MPC_REPO"); p != "" {
|
||||
return p
|
||||
}
|
||||
const def = "/Users/z/work/lux/mpc"
|
||||
if _, err := os.Stat(filepath.Join(def, "cmd", "mpcd")); err == nil {
|
||||
return def
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func liveFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// randID returns a random lowercase hex id (stdlib only — the backend module
|
||||
// carries no uuid dependency and we keep it that way).
|
||||
func randID(t *testing.T) string {
|
||||
t.Helper()
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// liveEnsemble is a running mpcd ensemble for the backend proof.
|
||||
type liveEnsemble struct {
|
||||
t *testing.T
|
||||
apiAddrs []string
|
||||
procs []*exec.Cmd
|
||||
logs []*lockedBuf
|
||||
}
|
||||
|
||||
type lockedBuf struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockedBuf) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
func (b *lockedBuf) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// startLiveEnsemble builds mpcd from the lux/mpc repo and launches an n-node,
|
||||
// t-of-n consensus ensemble, waiting until all nodes report quorum + readiness.
|
||||
func startLiveEnsemble(t *testing.T, repo string, n, threshold int) *liveEnsemble {
|
||||
t.Helper()
|
||||
|
||||
bin := filepath.Join(t.TempDir(), "mpcd")
|
||||
build := exec.Command("go", "build", "-o", bin, "./cmd/mpcd")
|
||||
build.Dir = repo
|
||||
build.Env = append(os.Environ(),
|
||||
"GOWORK=off",
|
||||
"GOEXPERIMENT=runtimesecret,simd",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
var bout bytes.Buffer
|
||||
build.Stdout, build.Stderr = &bout, &bout
|
||||
t.Logf("building mpcd from %s …", repo)
|
||||
if err := build.Run(); err != nil {
|
||||
t.Fatalf("build mpcd: %v\n%s", err, bout.String())
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
nodeIDs := make([]string, n)
|
||||
listen := make([]int, n)
|
||||
api := make([]int, n)
|
||||
for i := range nodeIDs {
|
||||
nodeIDs[i] = fmt.Sprintf("node%d", i)
|
||||
listen[i] = liveFreePort(t)
|
||||
api[i] = liveFreePort(t)
|
||||
}
|
||||
|
||||
e := &liveEnsemble{t: t, apiAddrs: make([]string, n), procs: make([]*exec.Cmd, n), logs: make([]*lockedBuf, n)}
|
||||
for i := 0; i < n; i++ {
|
||||
args := []string{
|
||||
"start",
|
||||
"--node-id", nodeIDs[i],
|
||||
"--listen", fmt.Sprintf("127.0.0.1:%d", listen[i]),
|
||||
"--api", fmt.Sprintf("127.0.0.1:%d", api[i]),
|
||||
"--data", filepath.Join(root, nodeIDs[i]),
|
||||
"--keys", filepath.Join(root, nodeIDs[i], "keys"),
|
||||
"--threshold", fmt.Sprint(threshold),
|
||||
"--log-level", "warn",
|
||||
"--kms-zap-listen", "",
|
||||
"--threshold-listen", "",
|
||||
}
|
||||
for j := 0; j < n; j++ {
|
||||
if j != i {
|
||||
args = append(args, "--peer", fmt.Sprintf("%s@127.0.0.1:%d", nodeIDs[j], listen[j]))
|
||||
}
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"MPC_HSM_PROVIDER=env",
|
||||
"MPC_PASSWORD="+liveZapDBPassword,
|
||||
"ZAPDB_PASSWORD="+liveZapDBPassword,
|
||||
"MPC_INTERNAL_API_KEY="+liveInternalAPIKey,
|
||||
)
|
||||
lb := &lockedBuf{}
|
||||
cmd.Stdout, cmd.Stderr = lb, lb
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start %s: %v", nodeIDs[i], err)
|
||||
}
|
||||
e.procs[i] = cmd
|
||||
e.logs[i] = lb
|
||||
e.apiAddrs[i] = fmt.Sprintf("127.0.0.1:%d", api[i])
|
||||
}
|
||||
t.Cleanup(e.stop)
|
||||
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
ok := true
|
||||
for _, addr := range e.apiAddrs {
|
||||
resp, err := http.Get("http://" + addr + "/healthz")
|
||||
if err != nil {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
var h struct {
|
||||
SigningQuorum bool `json:"signing_quorum"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = json.Unmarshal(body, &h)
|
||||
if resp.StatusCode != http.StatusOK || !h.SigningQuorum || !h.Ready {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
t.Logf("live ensemble healthy: %d nodes, %d-of-%d quorum", n, threshold, n)
|
||||
return e
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
for i, lb := range e.logs {
|
||||
t.Logf("node%d log:\n%s", i, lb.String())
|
||||
}
|
||||
t.Fatal("live ensemble never reached readiness")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *liveEnsemble) stop() {
|
||||
for _, c := range e.procs {
|
||||
if c != nil && c.Process != nil {
|
||||
_ = c.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
}
|
||||
for _, c := range e.procs {
|
||||
if c == nil || c.Process == nil {
|
||||
continue
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func(c *exec.Cmd) { _, _ = c.Process.Wait(); close(done) }(c)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = c.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *liveEnsemble) logs0() string {
|
||||
var b strings.Builder
|
||||
for i, lb := range e.logs {
|
||||
fmt.Fprintf(&b, "=== node%d ===\n%s\n", i, lb.String())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestBackendLiveMPC drives the wallet backend's /v1 API against a LIVE 3-node
|
||||
// MPC ensemble — the live (not mock) backend↔MPC custody proof.
|
||||
func TestBackendLiveMPC(t *testing.T) {
|
||||
repo := mpcRepo()
|
||||
if repo == "" {
|
||||
t.Skip("lux/mpc repo not found (set LUX_MPC_REPO); skipping live backend↔MPC proof")
|
||||
}
|
||||
|
||||
// --- LIVE MPC ensemble (3 nodes, 2-of-3). custody points at node0. ---
|
||||
ens := startLiveEnsemble(t, repo, 3, 2)
|
||||
mpcEndpoint := "http://" + ens.apiAddrs[0]
|
||||
|
||||
// REAL custody adapter → live MPC node0, authenticated with the node's
|
||||
// internal API key (the production wire: Authorization: Bearer <token>).
|
||||
custodian, err := custody.NewMPC(custody.MPCConfig{
|
||||
Endpoint: mpcEndpoint,
|
||||
ServiceToken: liveInternalAPIKey,
|
||||
HTTPClient: &http.Client{Timeout: 90 * time.Second},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("custody.NewMPC: %v", err)
|
||||
}
|
||||
|
||||
// Path (a): REAL iam.Verifier against a local RSA OIDC issuer (newIDP +
|
||||
// tokenForOrg are shared with api_test.go). Standard discovery + JWKS.
|
||||
d := newIDP(t)
|
||||
v, err := iam.New(context.Background(), d.issuer, "lux-wallet", http.DefaultClient)
|
||||
if err != nil {
|
||||
t.Fatalf("iam.New: %v", err)
|
||||
}
|
||||
|
||||
// REAL api.Server: live custodian + real verifier + memory store.
|
||||
srv := New(custodian, store.NewMemory(), v, nil)
|
||||
h := srv.Handler()
|
||||
tok := d.tokenForOrg(t, "live-org", "user-1")
|
||||
|
||||
// --- 1) POST /v1/wallets → LIVE MPC keygen (real address, no key). ---
|
||||
w := do(t, h, "POST", "/v1/wallets", tok, "")
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("POST /v1/wallets: code=%d body=%s\nMPC logs:\n%s", w.Code, w.Body, ens.logs0())
|
||||
}
|
||||
var wallet custody.Wallet
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &wallet); err != nil {
|
||||
t.Fatalf("decode wallet: %v (%s)", err, w.Body)
|
||||
}
|
||||
if wallet.WalletID == "" {
|
||||
t.Fatalf("empty wallet id: %s", w.Body)
|
||||
}
|
||||
evm := wallet.Addresses["evm"]
|
||||
if evm == "" || !strings.HasPrefix(evm, "0x") || len(evm) != 42 {
|
||||
t.Fatalf("bad evm address %q in %s", evm, w.Body)
|
||||
}
|
||||
for _, banned := range []string{"private", "secret", "seed", "mnemonic", "privkey"} {
|
||||
if strings.Contains(strings.ToLower(w.Body.String()), banned) {
|
||||
t.Fatalf("create response leaked %q: %s", banned, w.Body)
|
||||
}
|
||||
}
|
||||
t.Logf("LIVE /v1/wallets OK: walletId=%s evm=%s", wallet.WalletID, evm)
|
||||
|
||||
// --- 2) GET /v1/wallets → lists the wallet just created. ---
|
||||
w = do(t, h, "GET", "/v1/wallets", tok, "")
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), wallet.WalletID) {
|
||||
t.Fatalf("GET /v1/wallets: code=%d body=%s", w.Code, w.Body)
|
||||
}
|
||||
t.Logf("LIVE GET /v1/wallets OK: wallet listed")
|
||||
|
||||
// --- 3) POST /v1/wallets/{id}/sign → LIVE threshold signature. ---
|
||||
digest := sha256.Sum256([]byte("backend live e2e tx for " + wallet.WalletID))
|
||||
payloadHash := "0x" + hex.EncodeToString(digest[:])
|
||||
idem := randID(t)
|
||||
signBody := fmt.Sprintf(
|
||||
`{"scheme":"secp256k1","chainId":96369,"payloadHash":%q,"idempotencyKey":%q}`,
|
||||
payloadHash, idem,
|
||||
)
|
||||
w = do(t, h, "POST", "/v1/wallets/"+wallet.WalletID+"/sign", tok, signBody)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST sign: code=%d body=%s\nMPC logs:\n%s", w.Code, w.Body, ens.logs0())
|
||||
}
|
||||
var res custody.SignResult
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("decode sign result: %v (%s)", err, w.Body)
|
||||
}
|
||||
if res.Signature == "" {
|
||||
t.Fatalf("empty signature: %s", w.Body)
|
||||
}
|
||||
sig := strings.TrimPrefix(res.Signature, "0x")
|
||||
if _, err := hex.DecodeString(sig); err != nil {
|
||||
t.Fatalf("signature not hex: %q", res.Signature)
|
||||
}
|
||||
t.Logf("LIVE /v1/wallets/{id}/sign OK: signature=%s… session=%s",
|
||||
sig[:min2(32, len(sig))], res.SessionID)
|
||||
|
||||
// --- 4) Idempotency through the backend: same idempotency key + same body
|
||||
// → the backend returns the same live signature (MPC cache). ---
|
||||
w2 := do(t, h, "POST", "/v1/wallets/"+wallet.WalletID+"/sign", tok, signBody)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("idempotent re-sign via backend: code=%d body=%s", w2.Code, w2.Body)
|
||||
}
|
||||
var res2 custody.SignResult
|
||||
_ = json.Unmarshal(w2.Body.Bytes(), &res2)
|
||||
if res2.Signature != res.Signature {
|
||||
t.Fatalf("backend idempotency broken: %q != %q", res2.Signature, res.Signature)
|
||||
}
|
||||
t.Logf("LIVE backend idempotency OK: identical signature on repeat")
|
||||
|
||||
// --- 5) Missing idempotency key through the backend → 400. ---
|
||||
w3 := do(t, h, "POST", "/v1/wallets/"+wallet.WalletID+"/sign", tok,
|
||||
fmt.Sprintf(`{"scheme":"secp256k1","chainId":96369,"payloadHash":%q}`, payloadHash))
|
||||
if w3.Code != http.StatusBadRequest {
|
||||
t.Fatalf("sign w/o idempotencyKey: code=%d, want 400 (%s)", w3.Code, w3.Body)
|
||||
}
|
||||
t.Logf("LIVE backend boundary OK: missing idempotencyKey → 400")
|
||||
|
||||
// --- 6) No plaintext key in any backend response or MPC node log. ---
|
||||
hay := strings.ToLower(w.Body.String() + " " + w2.Body.String() + " " + ens.logs0())
|
||||
for _, marker := range []string{"private_key", "privatekey", "privkey", "secret_share", "mnemonic", "seed phrase"} {
|
||||
if strings.Contains(hay, marker) {
|
||||
t.Errorf("possible key leak (marker %q)", marker)
|
||||
}
|
||||
}
|
||||
t.Logf("LIVE no-leak OK: no plaintext key in backend responses or MPC logs")
|
||||
}
|
||||
|
||||
func min2(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user