mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +00:00
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:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/luxfi/wallet/backend
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
@@ -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=
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user