Files
zeekay 414daf6d87 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.
2026-06-23 15:07:40 -07:00

76 lines
3.1 KiB
Go

// 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)
}