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.
This commit is contained in:
zeekay
2026-06-23 15:07:40 -07:00
parent 5bb9a3bce6
commit cb468f08e8
17 changed files with 1744 additions and 3 deletions
+20
View File
@@ -0,0 +1,20 @@
# Platform-native CI/CD — luxfi/wallet.
#
# Build+deploy intent owned by platform.hanzo.ai (NOT GitHub Actions). On
# webhook, platform builds these images on the lux arcd pools
# (lux-build-linux-{amd64,arm64}) and pushes to GHCR. See platform
# docs/PLATFORM_CI.md.
#
# Build-only: the lux web3 operator (lux.cloud) deploys the App(Wallet) backend
# via the Service CR in apps/backend/k8s/wallet-backend.yaml — not platform's
# deploy-executor (which targets the hanzo operator only). Bump the CR's
# .spec.image.tag to the built {{git.sha}} to roll.
build:
matrix:
- { os: linux, arch: amd64 }
- { os: linux, arch: arm64 }
dockerfile: ./apps/backend/Dockerfile
context: .
image: ghcr.io/luxfi/wallet-backend
tag-pattern: "{{git.sha}}"
push: true
+83 -3
View File
@@ -1,17 +1,27 @@
# `@luxfi/wallet`
**Project**: `luxfi/wallet` — canonical Lux Wallet upstream (web + mobile + extension).
**Project**: `luxfi/wallet` — canonical Lux Wallet upstream (web + mobile + extension + backend).
**Org**: Lux Industries Inc. (`luxfi`).
**Status**: `apps/web` builds clean. `apps/{extension,mobile}` retain upstream-shaped
src that requires an app-level refactor to compile against current `@l.x/*` npm
**Status**: `apps/web` builds clean. `apps/backend` (Go MPC custody server) builds
+ tests green (17 tests). `apps/{extension,mobile}` retain upstream-shaped src
that requires an app-level refactor to compile against current `@l.x/*` npm
packages. The canonical bones (`pkgs/{wallet,brand,analytics}`) are stable.
> The freshly-scaffolded MIT TS shared core lives in a SEPARATE org —
> `github.com/luxwallet/*` (`@luxwallet/{chains,rpc,crypto,keyring,tx,sdk}`,
> + `connect`, `ui`, `desktop`, native ios/android/mobile-rn). That is the
> cross-target shared core; `luxfi/wallet` here is the GPL product monorepo
> (web/extension/mobile FE + this Go custody backend + PQ stack). The backend's
> custody port mirrors `@luxwallet/keyring`'s account model + the lux/mpc
> `client.Threshold` security contract.
## Canonical structure
```
luxfi/wallet/
├── apps/
│ ├── web/ — Vite 8 SPA, React 19, brand-aware. Builds in <100ms.
│ ├── backend/ — Go MPC custody server (App(Wallet) backend). See below.
│ ├── extension/ — Chrome/Firefox MV3 (upstream-shaped; app-refactor pending).
│ └── mobile/ — React Native + Expo (upstream-shaped; app-refactor pending).
├── pkgs/
@@ -137,6 +147,76 @@ The web SPA builds because Vite tree-shakes — only used surface is touched.
- `~/work/lux/xwallet` — OKX-fork lineage. Hardware support already removed → archive.
- `~/work/lux/dwallet` — Desktop product. **Independent**, do not fold in.
## `apps/backend` — App(Wallet) custody server (Go)
The wallet's BACKEND. A small Go HTTP service that gives the wallet a custody
account model with **MPC custody — NO plaintext keys, ever**. The private key
is split t-of-n across the MPC nodes (lux → `mpc.lux.network`) and is never
assembled; this service stores only public keys + addresses.
```
apps/backend/
├── cmd/wallet-backend/main.go — env config, graceful shutdown, JSON logging
└── internal/
├── iam/ — lux.id OIDC bearer verify (JWKS discovery, RS256/ES256,
│ alg-confusion-safe). Org from the `owner` claim. (golang-jwt/v5)
├── custody/ — the custody PORT (Custodian iface) + MPC HTTP adapter to
│ lux/mpc (/keygen + /sign). Mirrors lux/mpc client.Threshold's
│ security contract (idempotency required, org-scoped). PQ-ready
│ (Scheme mldsa65 is first-class).
├── store/ — wallet↔org↔addresses (no key field). Org-scoped reads (a
│ cross-org get is ErrNotFound — no existence disclosure).
└── api/ — /v1/* HTTP (no /api/ prefix). IAM-gated, org-scoped:
POST /v1/wallets · GET /v1/wallets · GET /v1/wallets/{id} ·
POST /v1/wallets/{id}/sign · GET /v1/health (open).
```
Run: `cd apps/backend && go test ./...` (17 tests). Build standalone with
`GOWORK=off go build ./cmd/wallet-backend` (the monorepo go.work also lists it).
Deploy: build-only `.platform.yml` (repo root) → `ghcr.io/luxfi/wallet-backend`;
the lux operator rolls `apps/backend/k8s/wallet-backend.yaml` (lux.cloud/v1
Service + KMSSecret for `MPC_SERVICE_TOKEN`), ingress `wallet-api.lux.network`.
White-label is per-tenant + per-env injection — `IAM_ISSUER` (lux → lux.id),
`IAM_AUDIENCE` (`<org>-wallet`), `MPC_ENDPOINT` (lux → mpc.lux.network; shared
hanzo-mpc or BYOMPC). Same binary, any brand. PQ crypto is NOT duplicated here —
ML-DSA/SLH-DSA live in `pkgs/wallet/.../pq` and on the consensus/precompile side;
the custody layer just carries `mldsa65` as a scheme through to MPC.
## App(Wallet) spec — for the unified operator (`App` Kind)
The unified web3 operator's `App` Kind deploys the wallet, white-labeled by
domain. The wallet App is **two deployables**, one OSS core, branded per tenant:
| Part | What | Image | Host |
|------|------|-------|------|
| Web wallet | `apps/web` Vite SPA, runtime brand.json (no source fork) | `ghcr.io/luxfi/wallet-web` | `wallet.<brand>` |
| Custody backend | `apps/backend` Go MPC custody server | `ghcr.io/luxfi/wallet-backend` | `wallet-api.<brand>` |
`App(Wallet)` CR shape (operator renders Service(s) + Ingress + KMSSecret):
```yaml
apiVersion: lux.cloud/v1
kind: App
metadata: { name: lux-wallet }
spec:
kind: wallet # curated view: balances/send/receive/PQ identity
source: { mode: native } # native luxwallet | override → tenant fork
web: { image: ghcr.io/luxfi/wallet-web, host: wallet.lux.network }
backend:{ image: ghcr.io/luxfi/wallet-backend, host: wallet-api.lux.network }
iam: https://lux.id # per-tenant (lux→lux.id)
mpc: https://mpc.lux.network # per-tenant: shared hanzo-mpc | BYOMPC
chainDefault: 96369 # Lux C-Chain
```
Custody = the `MPC` Kind (shared or BYO). The wallet App references {iam, mpc}
endpoints; the operator wires the KMSSecret (`MPC_SERVICE_TOKEN`). Native
binaries (mac/win/linux/ios/android/extension) build on the NATIVE CI fleet
(lux arcd, NO GHA) + codesign via `hanzoai/ci-signing/sign-<plat>@v1`, per
brand, served from the per-brand download page on `wallet.<brand>`. The native
shells live in the `luxwallet/*` org (desktop/ios/android/extension); they embed
the shared `@luxwallet/sdk` core and point at this backend for custody.
## Rules for AI Assistants
1. **NEVER** write random summary files — update `LLM.md` only.
+19
View File
@@ -0,0 +1,19 @@
# wallet-backend — the canonical App(Wallet) custody server. Static Go binary,
# distroless runtime. No plaintext keys: custody is MPC (mpc.lux.network).
# Built by platform native CI (NO GitHub Actions).
FROM golang:1.26-alpine AS build
WORKDIR /src
# The module lives at apps/backend in the wallet monorepo; build it standalone
# (GOWORK=off) so the image doesn't need the monorepo go.work.
COPY apps/backend/go.mod apps/backend/go.sum ./
RUN go mod download
COPY apps/backend/ ./
ENV GOWORK=off CGO_ENABLED=0
RUN go build -trimpath -ldflags="-s -w" -o /out/wallet-backend ./cmd/wallet-backend
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/wallet-backend /wallet-backend
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/wallet-backend"]
+106
View File
@@ -0,0 +1,106 @@
// wallet-backend — the canonical App(Wallet) custody server.
//
// Config is env-only (12-factor); secrets arrive from KMS via the operator
// Service CR, never hardcoded. White-label is per-tenant: IAM_ISSUER (lux →
// lux.id) and MPC_ENDPOINT (lux → mpc.lux.network) are injected, so the same
// binary serves any brand against their own IAM + MPC.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/luxfi/wallet/backend/internal/api"
"github.com/luxfi/wallet/backend/internal/custody"
"github.com/luxfi/wallet/backend/internal/iam"
"github.com/luxfi/wallet/backend/internal/store"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg := loadConfig()
if err := run(cfg, log); err != nil {
log.Error("fatal", "err", err)
os.Exit(1)
}
}
type config struct {
addr string
iamIssuer string
iamAudience string
mpcEndpoint string
mpcToken string
}
func loadConfig() config {
return config{
addr: env("LISTEN_ADDR", ":8080"),
iamIssuer: env("IAM_ISSUER", "https://lux.id"),
iamAudience: env("IAM_AUDIENCE", "lux-wallet"),
mpcEndpoint: env("MPC_ENDPOINT", "https://mpc.lux.network"),
mpcToken: os.Getenv("MPC_SERVICE_TOKEN"),
}
}
func run(cfg config, log *slog.Logger) error {
hc := &http.Client{Timeout: 15 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
verifier, err := iam.New(ctx, cfg.iamIssuer, cfg.iamAudience, hc)
cancel()
if err != nil {
return err
}
custodian, err := custody.NewMPC(custody.MPCConfig{
Endpoint: cfg.mpcEndpoint,
ServiceToken: cfg.mpcToken,
})
if err != nil {
return err
}
srv := &http.Server{
Addr: cfg.addr,
Handler: api.New(custodian, store.NewMemory(), verifier, log).Handler(),
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 120 * time.Second, // MPC keygen can take ~60s
}
// Graceful shutdown on SIGINT/SIGTERM.
idle := make(chan struct{})
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Info("shutting down")
sctx, scancel := context.WithTimeout(context.Background(), 10*time.Second)
defer scancel()
if err := srv.Shutdown(sctx); err != nil {
log.Error("shutdown", "err", err)
}
close(idle)
}()
log.Info("wallet-backend listening", "addr", cfg.addr, "iam", cfg.iamIssuer, "mpc", cfg.mpcEndpoint)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
<-idle
return nil
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/luxfi/wallet/backend
go 1.26.4
require github.com/golang-jwt/jwt/v5 v5.3.1
+2
View File
@@ -0,0 +1,2 @@
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+188
View File
@@ -0,0 +1,188 @@
// Package api is the wallet backend's HTTP surface — the canonical App(Wallet)
// server. Every wallet route is gated by lux.id IAM and scoped to the caller's
// org. Paths are /v1/* (no /api/ prefix — house rule). No plaintext keys ever
// touch this layer: wallet creation and signing both go through MPC custody.
package api
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/luxfi/wallet/backend/internal/custody"
"github.com/luxfi/wallet/backend/internal/iam"
"github.com/luxfi/wallet/backend/internal/store"
)
// Server wires the custody backend, the wallet store, and IAM verification.
type Server struct {
custodian custody.Custodian
store store.Store
verifier *iam.Verifier
log *slog.Logger
}
func New(custodian custody.Custodian, st store.Store, v *iam.Verifier, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
return &Server{custodian: custodian, store: st, verifier: v, log: log}
}
// Handler returns the mux. /v1/health is open; every /v1/wallets* route is
// IAM-gated.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", s.health)
mux.Handle("POST /v1/wallets", s.authed(s.createWallet))
mux.Handle("GET /v1/wallets", s.authed(s.listWallets))
mux.Handle("GET /v1/wallets/{id}", s.authed(s.getWallet))
mux.Handle("POST /v1/wallets/{id}/sign", s.authed(s.signTx))
return mux
}
// --- middleware ---
type ctxKey int
const identityKey ctxKey = 0
// authed verifies the lux.id bearer token and injects the Identity into ctx.
func (s *Server) authed(h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := iam.BearerFromHeader(r.Header)
if err != nil {
writeErr(w, http.StatusUnauthorized, "missing bearer token")
return
}
id, err := s.verifier.Verify(r.Context(), raw)
if err != nil {
s.log.Warn("auth rejected", "err", err)
writeErr(w, http.StatusUnauthorized, "invalid token")
return
}
ctx := context.WithValue(r.Context(), identityKey, id)
h(w, r.WithContext(ctx))
})
}
func identityOf(ctx context.Context) *iam.Identity {
id, _ := ctx.Value(identityKey).(*iam.Identity)
return id
}
// --- handlers ---
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// createWallet runs an MPC keygen for the caller's org and records the public
// wallet. The 201 body has addresses + pubkeys — never a private key.
func (s *Server) createWallet(w http.ResponseWriter, r *http.Request) {
id := identityOf(r.Context())
wallet, err := s.custodian.CreateWallet(r.Context(), id.Org)
if err != nil {
s.log.Error("createWallet", "org", id.Org, "err", err)
writeErr(w, http.StatusBadGateway, "custody unavailable")
return
}
if err := s.store.Put(r.Context(), id.Org, wallet); err != nil {
writeErr(w, http.StatusInternalServerError, "failed to record wallet")
return
}
s.log.Info("wallet created", "org", id.Org, "walletId", wallet.WalletID)
writeJSON(w, http.StatusCreated, wallet)
}
func (s *Server) listWallets(w http.ResponseWriter, r *http.Request) {
id := identityOf(r.Context())
wallets, err := s.store.List(r.Context(), id.Org)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to list wallets")
return
}
writeJSON(w, http.StatusOK, map[string]any{"wallets": wallets})
}
func (s *Server) getWallet(w http.ResponseWriter, r *http.Request) {
id := identityOf(r.Context())
wallet, err := s.store.Get(r.Context(), id.Org, r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "wallet not found")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to read wallet")
return
}
writeJSON(w, http.StatusOK, wallet)
}
type signBody struct {
Scheme custody.Scheme `json:"scheme"`
ChainID int `json:"chainId"`
PayloadHash string `json:"payloadHash"`
IdempotencyKey string `json:"idempotencyKey"`
}
// signTx requests an MPC threshold signature over a payload hash for the org's
// wallet. The private key is never reconstructed.
func (s *Server) signTx(w http.ResponseWriter, r *http.Request) {
id := identityOf(r.Context())
walletID := r.PathValue("id")
// The wallet must belong to the caller's org (org-scoped Get; cross-org →
// 404, no existence disclosure).
if _, err := s.store.Get(r.Context(), id.Org, walletID); err != nil {
writeErr(w, http.StatusNotFound, "wallet not found")
return
}
var body signBody
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
if len(body.PayloadHash) == 0 {
writeErr(w, http.StatusBadRequest, "payloadHash required")
return
}
res, err := s.custodian.Sign(r.Context(), id.Org, custody.SignRequest{
WalletID: walletID,
Scheme: body.Scheme,
ChainID: body.ChainID,
PayloadHash: body.PayloadHash,
IdempotencyKey: body.IdempotencyKey,
})
if errors.Is(err, custody.ErrIdempotencyKeyRequired) {
writeErr(w, http.StatusBadRequest, "idempotencyKey required")
return
}
if errors.Is(err, custody.ErrUnsupportedScheme) {
writeErr(w, http.StatusBadRequest, "unsupported scheme")
return
}
if err != nil {
s.log.Error("sign", "org", id.Org, "walletId", walletID, "err", err)
writeErr(w, http.StatusBadGateway, "signing failed")
return
}
s.log.Info("tx signed", "org", id.Org, "walletId", walletID, "chainId", body.ChainID)
writeJSON(w, http.StatusOK, res)
}
// --- helpers ---
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
+206
View File
@@ -0,0 +1,206 @@
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)
}
}
+89
View File
@@ -0,0 +1,89 @@
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)
}
}
+75
View File
@@ -0,0 +1,75 @@
// Package custody is the wallet backend's custody port — the ONE interface the
// API depends on to create wallets and sign transactions. Custody is MPC: the
// private key is split t-of-n across the MPC nodes and NEVER exists in one
// place, so this backend stores public keys + addresses only — no plaintext
// key, ever.
//
// The port mirrors the security contract of github.com/luxfi/mpc/client's
// Threshold interface (idempotency, org-scoping) but the wallet owns its own
// minimal interface (decomplect: depend on a value we define, not on MPC's
// whole module). The HTTP adapter (mpc.go) speaks the real lux/mpc wire.
package custody
import (
"context"
"errors"
)
// Scheme is a signature scheme. PQ schemes (mldsa65) are first-class — the
// custody layer is PQ-ready; the PQ primitives themselves live in the wallet's
// pkgs/wallet/src/features/wallet/pq (Go-verified ML-DSA), not duplicated here.
type Scheme string
const (
SchemeSecp256k1 Scheme = "secp256k1" // EVM / BTC
SchemeEd25519 Scheme = "ed25519" // Solana
SchemeMLDSA65 Scheme = "mldsa65" // post-quantum (FIPS-204)
)
// Wallet is a custody wallet: an MPC-held key referenced by WalletID, with its
// derived public addresses. There is no field for a private key — by design.
type Wallet struct {
WalletID string `json:"walletId"`
ECDSAPubKey string `json:"ecdsaPubKey,omitempty"`
EDDSAPubKey string `json:"eddsaPubKey,omitempty"`
Addresses map[string]string `json:"addresses"` // chain → address (evm, btc, sol)
}
// SignRequest is a custody signing request. PayloadHash is the 32-byte tx hash
// to sign; the backend never sees or reconstructs a private key. Idempotency is
// required — a replayed key with a different payload MUST be refused (anti-
// oracle), matching the MPC client contract.
type SignRequest struct {
WalletID string
Scheme Scheme
ChainID int
PayloadHash string // 32-byte hex
IdempotencyKey string
}
// SignResult carries the signature bytes (hex). The signature is produced by
// the MPC threshold protocol; no key reconstruction occurs.
type SignResult struct {
Signature string `json:"signature"`
SessionID string `json:"sessionId,omitempty"`
}
var (
ErrIdempotencyKeyRequired = errors.New("custody: idempotency key required")
ErrUnsupportedScheme = errors.New("custody: unsupported scheme")
ErrBackendUnavailable = errors.New("custody: backend unavailable")
ErrKeygenFailed = errors.New("custody: keygen failed")
)
// Custodian is the MPC custody backend. org scopes every call to one tenant —
// the API derives it from the verified lux.id identity, never from the client.
type Custodian interface {
// CreateWallet runs an MPC distributed keygen for the org and returns the
// resulting Wallet (public keys + addresses). The private key is generated
// in shares across the MPC nodes and is never assembled.
CreateWallet(ctx context.Context, org string) (*Wallet, error)
// Sign requests an MPC threshold signature over req.PayloadHash for the
// org's wallet. No private key is reconstructed.
Sign(ctx context.Context, org string, req SignRequest) (*SignResult, error)
}
+169
View File
@@ -0,0 +1,169 @@
// MPC custody adapter — speaks the lux/mpc HTTP wire (mpc.lux.network).
//
// Wallet creation calls MPC `POST /keygen {org_id, wallet_id}` (the verified
// contract: returns ecdsa/eddsa pubkeys + evm/btc/sol addresses, never a
// private key). Signing calls `POST /sign` with the org-scoped wallet + payload
// hash and idempotency key, conforming to the MPC client's Sign contract.
//
// The MPC endpoint is per-tenant (shared hanzo-mpc or BYOMPC; lux →
// mpc.lux.network) — the URL is injected, so a tenant points custody at their
// own MPC without a code change. Auth to MPC is a service token (the MPC nodes'
// internalAuth); user identity/org is carried in the body and re-derived by MPC
// from its own bearer claims at the consensus layer.
package custody
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// MPCConfig configures the lux/mpc adapter. Endpoint is the MPC API base
// (e.g. https://mpc.lux.network); ServiceToken authenticates to the MPC nodes.
type MPCConfig struct {
Endpoint string
ServiceToken string
HTTPClient *http.Client
}
// mpcCustodian is the HTTP-backed Custodian.
type mpcCustodian struct {
endpoint string
token string
hc *http.Client
}
// NewMPC builds an MPC-backed Custodian. Returns an error if no endpoint is
// configured — there is no in-process fallback custody (no plaintext keys, ever).
func NewMPC(cfg MPCConfig) (Custodian, error) {
if strings.TrimSpace(cfg.Endpoint) == "" {
return nil, fmt.Errorf("%w: no MPC endpoint configured", ErrBackendUnavailable)
}
hc := cfg.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 90 * time.Second} // keygen can take ~60s
}
return &mpcCustodian{
endpoint: strings.TrimRight(cfg.Endpoint, "/"),
token: cfg.ServiceToken,
hc: hc,
}, nil
}
// keygenResp mirrors lux/mpc cmd/mpcd /keygen response.
type keygenResp struct {
WalletID string `json:"wallet_id"`
ResultType string `json:"result_type"`
ECDSAPubKey string `json:"ecdsa_pub_key"`
EDDSAPubKey string `json:"eddsa_pub_key"`
EVMAddress string `json:"evm_address"`
BTCAddress string `json:"btc_address"`
SOLAddress string `json:"sol_address"`
Error string `json:"error"`
}
func (m *mpcCustodian) CreateWallet(ctx context.Context, org string) (*Wallet, error) {
var resp keygenResp
if err := m.post(ctx, "/keygen", map[string]string{"org_id": org}, &resp); err != nil {
return nil, err
}
if resp.ResultType != "" && resp.ResultType != "success" {
return nil, fmt.Errorf("%w: %s", ErrKeygenFailed, resp.Error)
}
if resp.WalletID == "" {
return nil, fmt.Errorf("%w: empty wallet id", ErrKeygenFailed)
}
addrs := map[string]string{}
if resp.EVMAddress != "" {
addrs["evm"] = resp.EVMAddress
}
if resp.BTCAddress != "" {
addrs["btc"] = resp.BTCAddress
}
if resp.SOLAddress != "" {
addrs["sol"] = resp.SOLAddress
}
return &Wallet{
WalletID: resp.WalletID,
ECDSAPubKey: resp.ECDSAPubKey,
EDDSAPubKey: resp.EDDSAPubKey,
Addresses: addrs,
}, nil
}
// signReq is the org-scoped MPC sign request. Conforms to the MPC client
// SignRequest contract: org-scoped, payload-hash-bound, idempotent.
type signReq struct {
OrgID string `json:"org_id"`
WalletID string `json:"wallet_id"`
KeyType string `json:"key_type"`
ChainID int `json:"chain_id"`
PayloadHash string `json:"payload_hash"`
IdempotencyKey string `json:"idempotency_key"`
}
type signResp struct {
SessionID string `json:"session_id"`
Signature string `json:"signature"`
Status string `json:"status"`
Error string `json:"error"`
}
func (m *mpcCustodian) Sign(ctx context.Context, org string, req SignRequest) (*SignResult, error) {
if req.IdempotencyKey == "" {
return nil, ErrIdempotencyKeyRequired
}
switch req.Scheme {
case SchemeSecp256k1, SchemeEd25519, SchemeMLDSA65:
default:
return nil, fmt.Errorf("%w: %q", ErrUnsupportedScheme, req.Scheme)
}
body := signReq{
OrgID: org,
WalletID: req.WalletID,
KeyType: string(req.Scheme),
ChainID: req.ChainID,
PayloadHash: req.PayloadHash,
IdempotencyKey: req.IdempotencyKey,
}
var resp signResp
if err := m.post(ctx, "/sign", body, &resp); err != nil {
return nil, err
}
if resp.Error != "" {
return nil, fmt.Errorf("custody: sign rejected: %s", resp.Error)
}
return &SignResult{Signature: resp.Signature, SessionID: resp.SessionID}, nil
}
// post sends a JSON body to an MPC endpoint and decodes the JSON response.
func (m *mpcCustodian) post(ctx context.Context, path string, body, out interface{}) error {
buf, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.endpoint+path, bytes.NewReader(buf))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if m.token != "" {
req.Header.Set("Authorization", "Bearer "+m.token)
}
res, err := m.hc.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", ErrBackendUnavailable, err)
}
defer res.Body.Close()
if res.StatusCode == http.StatusServiceUnavailable {
return fmt.Errorf("%w: MPC peers not ready", ErrBackendUnavailable)
}
if res.StatusCode/100 != 2 {
return fmt.Errorf("custody: MPC %s → status %d", path, res.StatusCode)
}
return json.NewDecoder(res.Body).Decode(out)
}
+138
View File
@@ -0,0 +1,138 @@
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)
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package iam verifies lux.id OIDC bearer tokens — the ONE identity provider
// for the wallet backend. No built-in accounts; the superuser is z@lux.network,
// an IAM user. Every wallet operation is scoped to the org taken from the
// token's `owner` claim (house convention).
//
// Tokens are asymmetric (RS256/ES256) and verified against lux.id's JWKS, which
// is discovered from the issuer's /.well-known/openid-configuration and cached.
// No shared secret — the backend never holds a signing key.
package iam
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
// Identity is the verified caller. Org scopes every data access; Subject is the
// stable user id; Email is for display/audit.
type Identity struct {
Subject string
Email string
Org string
}
// Claims is the lux.id token shape we read. `owner` is the canonical org claim
// (falls back to org/organization/tenant in resolveOrg).
type Claims struct {
Email string `json:"email"`
PreferredUser string `json:"preferred_username"`
Owner string `json:"owner"`
Org string `json:"org"`
Organization string `json:"organization"`
Tenant string `json:"tenant"`
jwt.RegisteredClaims
}
// Verifier validates bearer tokens against a single lux.id issuer + audience.
type Verifier struct {
issuer string
audience string
keys *jwks
}
// New builds a Verifier for an issuer (e.g. https://lux.id) and the audience
// this backend expects (its IAM client id, e.g. lux-wallet). The JWKS endpoint
// is discovered from the issuer and refreshed lazily.
func New(ctx context.Context, issuer, audience string, hc *http.Client) (*Verifier, error) {
issuer = strings.TrimRight(issuer, "/")
jwksURI, err := discoverJWKS(ctx, issuer, hc)
if err != nil {
return nil, fmt.Errorf("iam: discover jwks: %w", err)
}
return &Verifier{
issuer: issuer,
audience: audience,
keys: newJWKS(jwksURI, hc),
}, nil
}
var (
ErrNoToken = errors.New("iam: no bearer token")
ErrInvalidToken = errors.New("iam: invalid token")
ErrNoOrg = errors.New("iam: token carries no org claim")
)
// Verify parses + validates a raw JWT and returns the caller Identity. It
// enforces issuer, audience, expiry, and a non-empty org claim, and only
// accepts asymmetric signatures (RS*/ES*/PS*) — never `none`, never HMAC.
func (v *Verifier) Verify(ctx context.Context, raw string) (*Identity, error) {
claims := &Claims{}
parser := jwt.NewParser(
jwt.WithIssuer(v.issuer),
jwt.WithAudience(v.audience),
jwt.WithExpirationRequired(),
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "PS256"}),
)
_, err := parser.ParseWithClaims(raw, claims, func(t *jwt.Token) (interface{}, error) {
return v.keys.key(ctx, t)
})
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidToken, err)
}
org := resolveOrg(claims)
if org == "" {
return nil, ErrNoOrg
}
email := claims.Email
return &Identity{
Subject: claims.Subject,
Email: email,
Org: org,
}, nil
}
func resolveOrg(c *Claims) string {
for _, v := range []string{c.Owner, c.Org, c.Organization, c.Tenant} {
if v != "" {
return v
}
}
return ""
}
// BearerFromHeader extracts the raw token from an Authorization header.
func BearerFromHeader(h http.Header) (string, error) {
v := h.Get("Authorization")
if v == "" {
return "", ErrNoToken
}
const p = "Bearer "
if len(v) <= len(p) || !strings.EqualFold(v[:len(p)], p) {
return "", ErrNoToken
}
return strings.TrimSpace(v[len(p):]), nil
}
+173
View File
@@ -0,0 +1,173 @@
package iam
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// testIDP stands up a fake lux.id: a discovery doc + JWKS for one RSA key, and
// mints tokens with that key.
type testIDP struct {
srv *httptest.Server
key *rsa.PrivateKey
kid string
issuer string
}
func newTestIDP(t *testing.T) *testIDP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
idp := &testIDP{key: key, kid: "test-kid-1"}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"jwks_uri": idp.issuer + "/jwks"})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
pub := key.PublicKey
eb := big.NewInt(int64(pub.E)).Bytes()
json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]string{{
"kty": "RSA",
"kid": idp.kid,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(eb),
}},
})
})
idp.srv = httptest.NewServer(mux)
idp.issuer = idp.srv.URL
return idp
}
func (idp *testIDP) token(t *testing.T, claims Claims) string {
t.Helper()
if claims.Issuer == "" {
claims.Issuer = idp.issuer
}
if len(claims.Audience) == 0 {
claims.Audience = jwt.ClaimStrings{"lux-wallet"}
}
if claims.ExpiresAt == nil {
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour))
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = idp.kid
s, err := tok.SignedString(idp.key)
if err != nil {
t.Fatal(err)
}
return s
}
func newVerifier(t *testing.T, idp *testIDP) *Verifier {
t.Helper()
v, err := New(context.Background(), idp.issuer, "lux-wallet", http.DefaultClient)
if err != nil {
t.Fatalf("New verifier: %v", err)
}
return v
}
func TestVerify_ValidToken_ResolvesOrgFromOwner(t *testing.T) {
idp := newTestIDP(t)
defer idp.srv.Close()
v := newVerifier(t, idp)
raw := idp.token(t, Claims{
Owner: "acme",
Email: "z@lux.network",
RegisteredClaims: jwt.RegisteredClaims{Subject: "user-1"},
})
id, err := v.Verify(context.Background(), raw)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if id.Org != "acme" {
t.Errorf("org = %q, want acme", id.Org)
}
if id.Subject != "user-1" || id.Email != "z@lux.network" {
t.Errorf("identity = %+v", id)
}
}
func TestVerify_NoOrgClaim_Rejected(t *testing.T) {
idp := newTestIDP(t)
defer idp.srv.Close()
v := newVerifier(t, idp)
raw := idp.token(t, Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "user-1"}})
if _, err := v.Verify(context.Background(), raw); err != ErrNoOrg {
t.Errorf("err = %v, want ErrNoOrg", err)
}
}
func TestVerify_WrongAudience_Rejected(t *testing.T) {
idp := newTestIDP(t)
defer idp.srv.Close()
v := newVerifier(t, idp)
raw := idp.token(t, Claims{
Owner: "acme",
RegisteredClaims: jwt.RegisteredClaims{Subject: "u", Audience: jwt.ClaimStrings{"some-other-app"}},
})
if _, err := v.Verify(context.Background(), raw); err == nil {
t.Error("expected rejection for wrong audience")
}
}
func TestVerify_Expired_Rejected(t *testing.T) {
idp := newTestIDP(t)
defer idp.srv.Close()
v := newVerifier(t, idp)
raw := idp.token(t, Claims{
Owner: "acme",
RegisteredClaims: jwt.RegisteredClaims{Subject: "u", ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour))},
})
if _, err := v.Verify(context.Background(), raw); err == nil {
t.Error("expected rejection for expired token")
}
}
func TestVerify_HMACToken_Rejected(t *testing.T) {
// An attacker presenting an HS256 token (alg confusion) must be refused —
// we only accept asymmetric methods.
idp := newTestIDP(t)
defer idp.srv.Close()
v := newVerifier(t, idp)
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
Owner: "acme",
RegisteredClaims: jwt.RegisteredClaims{Issuer: idp.issuer, Audience: jwt.ClaimStrings{"lux-wallet"}, Subject: "u", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
})
tok.Header["kid"] = idp.kid
raw, _ := tok.SignedString([]byte("guessed-secret"))
if _, err := v.Verify(context.Background(), raw); err == nil {
t.Error("expected rejection for HMAC-signed token (alg confusion)")
}
}
func TestBearerFromHeader(t *testing.T) {
h := http.Header{}
if _, err := BearerFromHeader(h); err != ErrNoToken {
t.Errorf("empty: err = %v", err)
}
h.Set("Authorization", "Bearer abc.def.ghi")
got, err := BearerFromHeader(h)
if err != nil || got != "abc.def.ghi" {
t.Errorf("got %q, %v", got, err)
}
}
+193
View File
@@ -0,0 +1,193 @@
// JWKS discovery + key cache. Stdlib only (no jose/keyfunc dep): fetch the
// issuer's JWKS, decode RSA/EC public keys, and resolve a token's `kid` to its
// key. Keys are cached; a cache miss (rotated kid) triggers one refresh.
package iam
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"math/big"
"net/http"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
type discoveryDoc struct {
JWKSURI string `json:"jwks_uri"`
}
func discoverJWKS(ctx context.Context, issuer string, hc *http.Client) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuer+"/.well-known/openid-configuration", nil)
if err != nil {
return "", err
}
res, err := hc.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("discovery %s: status %d", issuer, res.StatusCode)
}
var doc discoveryDoc
if err := json.NewDecoder(res.Body).Decode(&doc); err != nil {
return "", err
}
if doc.JWKSURI == "" {
return "", fmt.Errorf("discovery %s: no jwks_uri", issuer)
}
return doc.JWKSURI, nil
}
type jwk struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
N string `json:"n"` // RSA modulus
E string `json:"e"` // RSA exponent
Crv string `json:"crv"` // EC curve
X string `json:"x"` // EC x
Y string `json:"y"` // EC y
}
type jwks struct {
uri string
hc *http.Client
mu sync.RWMutex
keys map[string]interface{} // kid → *rsa.PublicKey | *ecdsa.PublicKey
fetchedAt time.Time
}
func newJWKS(uri string, hc *http.Client) *jwks {
return &jwks{uri: uri, hc: hc, keys: map[string]interface{}{}}
}
// key resolves the token's `kid` to its public key, refreshing once on a miss
// (handles key rotation) but no more than every 60s (avoids fetch storms).
func (j *jwks) key(ctx context.Context, t *jwt.Token) (interface{}, error) {
kid, _ := t.Header["kid"].(string)
if kid == "" {
return nil, fmt.Errorf("token has no kid")
}
if k := j.lookup(kid); k != nil {
return k, nil
}
j.mu.RLock()
stale := time.Since(j.fetchedAt) > time.Minute
j.mu.RUnlock()
if stale || j.empty() {
if err := j.refresh(ctx); err != nil {
return nil, err
}
}
if k := j.lookup(kid); k != nil {
return k, nil
}
return nil, fmt.Errorf("no key for kid %q", kid)
}
func (j *jwks) lookup(kid string) interface{} {
j.mu.RLock()
defer j.mu.RUnlock()
return j.keys[kid]
}
func (j *jwks) empty() bool {
j.mu.RLock()
defer j.mu.RUnlock()
return len(j.keys) == 0
}
func (j *jwks) refresh(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, j.uri, nil)
if err != nil {
return err
}
res, err := j.hc.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("jwks %s: status %d", j.uri, res.StatusCode)
}
var doc struct {
Keys []jwk `json:"keys"`
}
if err := json.NewDecoder(res.Body).Decode(&doc); err != nil {
return err
}
parsed := make(map[string]interface{}, len(doc.Keys))
for _, k := range doc.Keys {
pub, err := k.publicKey()
if err != nil || pub == nil {
continue // skip keys we can't parse rather than failing the whole set
}
parsed[k.Kid] = pub
}
if len(parsed) == 0 {
return fmt.Errorf("jwks %s: no usable keys", j.uri)
}
j.mu.Lock()
j.keys = parsed
j.fetchedAt = time.Now()
j.mu.Unlock()
return nil
}
func (k jwk) publicKey() (interface{}, error) {
switch k.Kty {
case "RSA":
nb, err := b64uint(k.N)
if err != nil {
return nil, err
}
eb, err := base64.RawURLEncoding.DecodeString(k.E)
if err != nil {
return nil, err
}
// Right-align the exponent into 8 bytes for a uint64.
var ebuf [8]byte
copy(ebuf[8-len(eb):], eb)
return &rsa.PublicKey{N: nb, E: int(binary.BigEndian.Uint64(ebuf[:]))}, nil
case "EC":
var curve elliptic.Curve
switch k.Crv {
case "P-256":
curve = elliptic.P256()
case "P-384":
curve = elliptic.P384()
case "P-521":
curve = elliptic.P521()
default:
return nil, fmt.Errorf("unsupported EC curve %q", k.Crv)
}
xb, err := b64uint(k.X)
if err != nil {
return nil, err
}
yb, err := b64uint(k.Y)
if err != nil {
return nil, err
}
return &ecdsa.PublicKey{Curve: curve, X: xb, Y: yb}, nil
default:
return nil, fmt.Errorf("unsupported kty %q", k.Kty)
}
}
func b64uint(s string) (*big.Int, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return nil, err
}
return new(big.Int).SetBytes(b), nil
}
+71
View File
@@ -0,0 +1,71 @@
// Package store persists the wallet↔org mapping and the wallet's public
// addresses. It NEVER stores a private key (custody is MPC). Org scoping is
// enforced here: a wallet read/list is always filtered by org, so one tenant
// can never see another's wallets.
package store
import (
"context"
"errors"
"sync"
"github.com/luxfi/wallet/backend/internal/custody"
)
var ErrNotFound = errors.New("store: wallet not found")
// Store is the wallet record store. All methods are org-scoped.
type Store interface {
// Put records a wallet under an org. Idempotent on WalletID within the org.
Put(ctx context.Context, org string, w *custody.Wallet) error
// Get returns the org's wallet by id, or ErrNotFound (also for a wallet
// owned by a different org — no cross-org existence disclosure).
Get(ctx context.Context, org, walletID string) (*custody.Wallet, error)
// List returns all wallets for an org.
List(ctx context.Context, org string) ([]*custody.Wallet, error)
}
// Memory is an in-process Store. Production swaps in a Postgres-backed Store
// behind the same interface; the API is indifferent. Records hold public data
// only — there is no key field to leak.
type Memory struct {
mu sync.RWMutex
// org → walletID → wallet
byOrg map[string]map[string]*custody.Wallet
}
func NewMemory() *Memory {
return &Memory{byOrg: map[string]map[string]*custody.Wallet{}}
}
func (m *Memory) Put(_ context.Context, org string, w *custody.Wallet) error {
m.mu.Lock()
defer m.mu.Unlock()
wallets := m.byOrg[org]
if wallets == nil {
wallets = map[string]*custody.Wallet{}
m.byOrg[org] = wallets
}
wallets[w.WalletID] = w
return nil
}
func (m *Memory) Get(_ context.Context, org, walletID string) (*custody.Wallet, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if w, ok := m.byOrg[org][walletID]; ok {
return w, nil
}
return nil, ErrNotFound
}
func (m *Memory) List(_ context.Context, org string) ([]*custody.Wallet, error) {
m.mu.RLock()
defer m.mu.RUnlock()
wallets := m.byOrg[org]
out := make([]*custody.Wallet, 0, len(wallets))
for _, w := range wallets {
out = append(out, w)
}
return out, nil
}
+87
View File
@@ -0,0 +1,87 @@
# wallet-backend — the App(Wallet) custody server, deployed by the unified lux
# web3 operator (group lux.cloud) as a Service CR. Custody is MPC
# (mpc.lux.network); the only secret is the MPC service token, synced from Lux
# KMS via the KMSSecret below — NEVER in this manifest, NEVER plaintext.
---
apiVersion: secrets.lux.network/v1alpha1
kind: KMSSecret
metadata:
name: wallet-backend-kms
namespace: lux-mainnet
labels:
app: wallet-backend
brand: lux
spec:
hostAPI: http://kms.lux-kms.svc.cluster.local
resyncInterval: 60
authentication:
universalAuth:
credentialsRef:
secretName: universal-auth-credentials
secretNamespace: lux-system
secretsScope:
projectSlug: lux-wallet
envSlug: prod
secretsPath: /
managedSecretReference:
creationPolicy: Orphan
secretName: wallet-backend-secrets
secretNamespace: lux-mainnet
secretType: Opaque
---
apiVersion: lux.cloud/v1
kind: Service
metadata:
name: wallet-backend
namespace: lux-mainnet
labels:
app: wallet-backend
brand: lux
component: api
spec:
image:
repository: ghcr.io/luxfi/wallet-backend
tag: latest
pullPolicy: Always
replicas: 2
strategy: RollingUpdate
ports:
- name: http
containerPort: 8080
# MPC_SERVICE_TOKEN arrives from the KMSSecret-managed Secret above.
envFrom:
- secretRef:
name: wallet-backend-secrets
env:
- name: LISTEN_ADDR
value: ":8080"
- name: IAM_ISSUER
value: https://lux.id
- name: IAM_AUDIENCE
value: lux-wallet
- name: MPC_ENDPOINT
value: https://mpc.lux.network
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
path: /v1/health
port: 8080
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
path: /v1/health
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
imagePullSecrets:
- name: ghcr-luxfi
ingress:
enabled: true
hosts:
- wallet-api.lux.network
tls: true