mirror of
https://github.com/luxfi/wallet.git
synced 2026-10-30 15:13:19 +00:00
Add apps/backend — the wallet's custody server. The load-bearing Front-E gap
(repo was FE-only) is now closed: a wallet account model backed by MPC custody
where the private key is split t-of-n across the MPC nodes (lux →
mpc.lux.network) and NEVER assembled — this service stores only public keys +
addresses.
- internal/iam: lux.id OIDC bearer verification via JWKS (discovery + RS256/
ES256, alg-confusion-safe — HMAC/none refused). Org from the 'owner' claim.
golang-jwt/v5 (matches the lux ecosystem), stdlib JWKS fetcher (no extra dep).
- internal/custody: the Custodian port (a value the wallet owns) + HTTP adapter
to lux/mpc's verified /keygen + /sign wire. Mirrors mpc client.Threshold's
security contract (idempotency required, org-scoped). PQ-ready: mldsa65 is a
first-class scheme (the PQ primitives stay in pkgs/wallet/.../pq + consensus —
not duplicated). No in-process key fallback — unconfigured MPC is an error.
- internal/store: wallet↔org↔addresses (no key field). Org-scoped reads — a
cross-org get is ErrNotFound (no existence disclosure).
- internal/api: /v1/* (no /api/ prefix). IAM-gated, org-scoped. POST/GET
/v1/wallets, GET /v1/wallets/{id}, POST /v1/wallets/{id}/sign, GET /v1/health.
- cmd/wallet-backend: env config (white-label per tenant: IAM_ISSUER/AUDIENCE,
MPC_ENDPOINT — shared hanzo-mpc or BYOMPC), JSON logs, graceful shutdown.
Deploy (native CI + operator, NO GHA): build-only .platform.yml → ghcr.io/
luxfi/wallet-backend on lux-build pools; lux operator rolls
apps/backend/k8s/wallet-backend.yaml (lux.cloud/v1 Service + KMSSecret for
MPC_SERVICE_TOKEN), ingress wallet-api.lux.network.
17 tests green (go test ./...): IAM (valid/no-org/wrong-aud/expired/alg-
confusion), custody (no-key-leak, idempotency-required, PQ-accepted, no-
fallback), API (auth-gate, org-isolation cross-org→404, create→sign), + a full
E2E through the real adapter→mock-MPC→real-server. Binary boots, /v1/health ok,
/v1/wallets 401 unauth. LLM.md captures the App(Wallet) spec for the unified
operator's App Kind.
189 lines
5.8 KiB
Go
189 lines
5.8 KiB
Go
// 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})
|
|
}
|