mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +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.
207 lines
6.0 KiB
Go
207 lines
6.0 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
"github.com/luxfi/wallet/backend/internal/custody"
|
|
"github.com/luxfi/wallet/backend/internal/iam"
|
|
"github.com/luxfi/wallet/backend/internal/store"
|
|
)
|
|
|
|
// --- test IDP (fake lux.id) ---
|
|
|
|
type idp struct {
|
|
srv *httptest.Server
|
|
key *rsa.PrivateKey
|
|
issuer string
|
|
}
|
|
|
|
func newIDP(t *testing.T) *idp {
|
|
t.Helper()
|
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
d := &idp{key: key}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]string{"jwks_uri": d.issuer + "/jwks"})
|
|
})
|
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
|
pub := key.PublicKey
|
|
json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{
|
|
"kty": "RSA", "kid": "k1",
|
|
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
|
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
|
|
}}})
|
|
})
|
|
d.srv = httptest.NewServer(mux)
|
|
d.issuer = d.srv.URL
|
|
t.Cleanup(d.srv.Close)
|
|
return d
|
|
}
|
|
|
|
func (d *idp) tokenForOrg(t *testing.T, org, sub string) string {
|
|
t.Helper()
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, iam.Claims{
|
|
Owner: org,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: d.issuer, Audience: jwt.ClaimStrings{"lux-wallet"},
|
|
Subject: sub, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
},
|
|
})
|
|
tok.Header["kid"] = "k1"
|
|
s, _ := tok.SignedString(d.key)
|
|
return s
|
|
}
|
|
|
|
// --- fake custodian (no real MPC) ---
|
|
|
|
type fakeCustodian struct{ n int }
|
|
|
|
func (f *fakeCustodian) CreateWallet(_ context.Context, org string) (*custody.Wallet, error) {
|
|
f.n++
|
|
return &custody.Wallet{
|
|
WalletID: org + "-wallet-" + string(rune('0'+f.n)),
|
|
Addresses: map[string]string{"evm": "0xabc"},
|
|
}, nil
|
|
}
|
|
func (f *fakeCustodian) Sign(_ context.Context, _ string, req custody.SignRequest) (*custody.SignResult, error) {
|
|
if req.IdempotencyKey == "" {
|
|
return nil, custody.ErrIdempotencyKeyRequired
|
|
}
|
|
return &custody.SignResult{Signature: "0xsig", SessionID: "s1"}, nil
|
|
}
|
|
|
|
func newServer(t *testing.T) (*Server, *idp) {
|
|
t.Helper()
|
|
d := newIDP(t)
|
|
v, err := iam.New(context.Background(), d.issuer, "lux-wallet", http.DefaultClient)
|
|
if err != nil {
|
|
t.Fatalf("verifier: %v", err)
|
|
}
|
|
return New(&fakeCustodian{}, store.NewMemory(), v, nil), d
|
|
}
|
|
|
|
func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var r *http.Request
|
|
if body != "" {
|
|
r = httptest.NewRequest(method, path, strings.NewReader(body))
|
|
} else {
|
|
r = httptest.NewRequest(method, path, nil)
|
|
}
|
|
if token != "" {
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
func TestHealth_Open(t *testing.T) {
|
|
s, _ := newServer(t)
|
|
w := do(t, s.Handler(), "GET", "/v1/health", "", "")
|
|
if w.Code != 200 {
|
|
t.Fatalf("health code = %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestWallets_RequireAuth(t *testing.T) {
|
|
s, _ := newServer(t)
|
|
for _, p := range []struct{ m, path string }{
|
|
{"POST", "/v1/wallets"}, {"GET", "/v1/wallets"}, {"GET", "/v1/wallets/x"},
|
|
{"POST", "/v1/wallets/x/sign"},
|
|
} {
|
|
w := do(t, s.Handler(), p.m, p.path, "", "")
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("%s %s without token: code = %d, want 401", p.m, p.path, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateAndListWallet_NoKeyLeak(t *testing.T) {
|
|
s, d := newServer(t)
|
|
h := s.Handler()
|
|
tok := d.tokenForOrg(t, "acme", "u1")
|
|
|
|
w := do(t, h, "POST", "/v1/wallets", tok, "")
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create code = %d body=%s", w.Code, w.Body)
|
|
}
|
|
body, _ := io.ReadAll(w.Body)
|
|
for _, banned := range []string{"private", "secret", "seed", "mnemonic", "privkey"} {
|
|
if strings.Contains(strings.ToLower(string(body)), banned) {
|
|
t.Errorf("create response leaked %q: %s", banned, body)
|
|
}
|
|
}
|
|
|
|
w = do(t, h, "GET", "/v1/wallets", tok, "")
|
|
if w.Code != 200 || !strings.Contains(w.Body.String(), "acme-wallet") {
|
|
t.Errorf("list code=%d body=%s", w.Code, w.Body)
|
|
}
|
|
}
|
|
|
|
func TestOrgScoping_CrossOrgWalletIsNotFound(t *testing.T) {
|
|
s, d := newServer(t)
|
|
h := s.Handler()
|
|
|
|
// acme creates a wallet.
|
|
w := do(t, h, "POST", "/v1/wallets", d.tokenForOrg(t, "acme", "u1"), "")
|
|
var wallet custody.Wallet
|
|
json.Unmarshal(w.Body.Bytes(), &wallet)
|
|
|
|
// evil-corp (different org) must NOT be able to read it — 404, not 200.
|
|
w = do(t, h, "GET", "/v1/wallets/"+wallet.WalletID, d.tokenForOrg(t, "evil-corp", "u2"), "")
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("cross-org read: code = %d, want 404 (no existence disclosure)", w.Code)
|
|
}
|
|
|
|
// And evil-corp's list is empty.
|
|
w = do(t, h, "GET", "/v1/wallets", d.tokenForOrg(t, "evil-corp", "u2"), "")
|
|
if strings.Contains(w.Body.String(), wallet.WalletID) {
|
|
t.Errorf("evil-corp list leaked acme wallet: %s", w.Body)
|
|
}
|
|
}
|
|
|
|
func TestSign_OrgScopedAndIdempotent(t *testing.T) {
|
|
s, d := newServer(t)
|
|
h := s.Handler()
|
|
tok := d.tokenForOrg(t, "acme", "u1")
|
|
|
|
w := do(t, h, "POST", "/v1/wallets", tok, "")
|
|
var wallet custody.Wallet
|
|
json.Unmarshal(w.Body.Bytes(), &wallet)
|
|
|
|
// Missing idempotency key → 400.
|
|
w = do(t, h, "POST", "/v1/wallets/"+wallet.WalletID+"/sign", tok,
|
|
`{"scheme":"secp256k1","chainId":96369,"payloadHash":"0xab"}`)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("sign w/o idempotency: code=%d, want 400", w.Code)
|
|
}
|
|
|
|
// Full request → 200 with a signature, no key.
|
|
w = do(t, h, "POST", "/v1/wallets/"+wallet.WalletID+"/sign", tok,
|
|
`{"scheme":"secp256k1","chainId":96369,"payloadHash":"0xab","idempotencyKey":"k1"}`)
|
|
if w.Code != 200 || !strings.Contains(w.Body.String(), "0xsig") {
|
|
t.Errorf("sign: code=%d body=%s", w.Code, w.Body)
|
|
}
|
|
|
|
// Cross-org sign on acme's wallet → 404.
|
|
w = do(t, h, "POST", "/v1/wallets/"+wallet.WalletID+"/sign", d.tokenForOrg(t, "evil-corp", "u2"),
|
|
`{"scheme":"secp256k1","chainId":96369,"payloadHash":"0xab","idempotencyKey":"k2"}`)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("cross-org sign: code=%d, want 404", w.Code)
|
|
}
|
|
}
|