mirror of
https://github.com/luxfi/wallet.git
synced 2026-10-30 15:13:19 +00:00
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.
90 lines
2.9 KiB
Go
90 lines
2.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/luxfi/wallet/backend/internal/custody"
|
|
"github.com/luxfi/wallet/backend/internal/iam"
|
|
"github.com/luxfi/wallet/backend/internal/store"
|
|
)
|
|
|
|
// TestE2E_RealCustodyAdapter_CreateThenSign wires the REAL MPC custody adapter
|
|
// (custody.NewMPC) to a mock lux/mpc that speaks the verified /keygen + /sign
|
|
// wire, behind the REAL api.Server and a REAL iam.Verifier. This proves the
|
|
// whole chain: lux.id auth → org scope → MPC keygen (addresses, no key) → MPC
|
|
// threshold sign — end to end, with no plaintext key anywhere.
|
|
func TestE2E_RealCustodyAdapter_CreateThenSign(t *testing.T) {
|
|
// Mock lux/mpc (verified wire).
|
|
mpcMux := http.NewServeMux()
|
|
mpcMux.HandleFunc("/keygen", func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
OrgID string `json:"org_id"`
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
if req.OrgID == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"wallet_id": "w-" + req.OrgID, "result_type": "success",
|
|
"ecdsa_pub_key": "04aa", "evm_address": "0x2222222222222222222222222222222222222222",
|
|
})
|
|
})
|
|
mpcMux.HandleFunc("/sign", func(w http.ResponseWriter, r *http.Request) {
|
|
var req map[string]any
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
if req["idempotency_key"] == "" || req["payload_hash"] == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"signature": "0xfeed", "status": "signed"})
|
|
})
|
|
mpcSrv := httptest.NewServer(mpcMux)
|
|
defer mpcSrv.Close()
|
|
|
|
custodian, err := custody.NewMPC(custody.MPCConfig{Endpoint: mpcSrv.URL, ServiceToken: "svc"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
d := newIDP(t)
|
|
v, err := iam.New(context.Background(), d.issuer, "lux-wallet", http.DefaultClient)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := New(custodian, store.NewMemory(), v, nil).Handler()
|
|
tok := d.tokenForOrg(t, "acme", "u1")
|
|
|
|
// 1) Create wallet via real adapter → real MPC keygen.
|
|
w := do(t, h, "POST", "/v1/wallets", tok, "")
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create: %d %s", w.Code, w.Body)
|
|
}
|
|
var wallet custody.Wallet
|
|
json.Unmarshal(w.Body.Bytes(), &wallet)
|
|
if wallet.WalletID != "w-acme" || wallet.Addresses["evm"] == "" {
|
|
t.Fatalf("wallet = %+v", wallet)
|
|
}
|
|
// No private key in the create response.
|
|
if strings.Contains(strings.ToLower(w.Body.String()), "priv") {
|
|
t.Fatalf("leaked key: %s", w.Body)
|
|
}
|
|
|
|
// 2) Sign a tx hash via real adapter → real MPC threshold sign.
|
|
w = do(t, h, "POST", "/v1/wallets/"+wallet.WalletID+"/sign", tok,
|
|
`{"scheme":"secp256k1","chainId":96369,"payloadHash":"0xdeadbeef","idempotencyKey":"idem-1"}`)
|
|
if w.Code != 200 {
|
|
t.Fatalf("sign: %d %s", w.Code, w.Body)
|
|
}
|
|
var res custody.SignResult
|
|
json.Unmarshal(w.Body.Bytes(), &res)
|
|
if res.Signature != "0xfeed" {
|
|
t.Fatalf("signature = %q", res.Signature)
|
|
}
|
|
}
|