mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
pkg/api embedded 56KB of genuine Geist and served it at /fonts/Geist-Variable.woff2, and the landing page's @font-face asked for it by name — so mpc.hanzo.ai painted its only pre-auth surface in a typeface the estate does not use. The bytes are Zen now; checked at the name table, family 'Zen', with the OFL notice the licence requires intact. The dashboard was worse than wrong, it was blank: its layout published --font-geist-sans off the geist package while tailwind.config.ts asked for --font-zen-sans, so font-sans resolved to nothing and every screen fell back to the browser default. The docs layout imported Zen under the name GeistSans, which compiles and lies.
589 lines
23 KiB
Go
589 lines
23 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimw "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
|
|
"github.com/luxfi/mpc/pkg/custody"
|
|
"github.com/luxfi/mpc/pkg/db"
|
|
"github.com/luxfi/mpc/pkg/txtracker"
|
|
"github.com/luxfi/mpc/pkg/types"
|
|
"github.com/luxfi/mpc/pkg/wallet"
|
|
"github.com/luxfi/mpc/pkg/webauthn"
|
|
)
|
|
|
|
// MPCBackend is the interface the API layer uses to trigger MPC operations.
|
|
// All mutation methods require orgID for tenant-scoped key storage.
|
|
//
|
|
// TriggerSign takes the network the signature is for. It is not optional and
|
|
// there is no curve parameter: a wallet holds a secp256k1 key and an Ed25519
|
|
// key at once, so the curve is a property of the request, and the backend
|
|
// derives it from the network through the one table in pkg/types. Callers name
|
|
// what they are signing for; they never name a curve.
|
|
type MPCBackend interface {
|
|
TriggerKeygen(orgID, walletID string) (*KeygenResult, error)
|
|
TriggerSign(orgID, walletID string, network types.NetworkCode, payload []byte) (*SignResult, error)
|
|
TriggerReshare(orgID, walletID string, newThreshold int, newParticipants []string) error
|
|
// TriggerReveal opens a ciphertext the caller brings, under the share set
|
|
// recorded for keyID. The ring answers with shares and stores nothing that
|
|
// needs opening, so the ciphertext is an argument and never a lookup.
|
|
TriggerReveal(orgID, keyID string, ciphertext []byte) ([]byte, error)
|
|
ExportKeyShare(orgID, walletID string) ([]byte, error) // Returns serialized key share for backup encryption
|
|
GetClusterStatus() *ClusterStatus
|
|
}
|
|
|
|
// KeygenResult is the backend response from a keygen operation.
|
|
//
|
|
// EVMAddress is the canonical 20-byte EVM-runtime account address —
|
|
// the format consumed by every EVM-compatible chain. The derivation
|
|
// hashes the secp256k1 pubkey with Keccak256 — that's HOW. The value
|
|
// IS "EVM-runtime account address" — that's WHAT.
|
|
type KeygenResult struct {
|
|
WalletID string `json:"wallet_id"`
|
|
ECDSAPubKey string `json:"ecdsa_pub_key"`
|
|
EDDSAPubKey string `json:"eddsa_pub_key"`
|
|
EVMAddress string `json:"evm_address,omitempty"`
|
|
BtcAddress string `json:"btc_address,omitempty"`
|
|
SolAddress string `json:"sol_address,omitempty"`
|
|
|
|
// Threshold is the number of signers required to use this key, and
|
|
// Participants is the configured party set it was generated across.
|
|
//
|
|
// These are reported so a caller can VERIFY the security property it asked
|
|
// for instead of assuming it. Keygen takes no per-request threshold — the
|
|
// ring's own --threshold governs — so without these fields a client that
|
|
// requests 3-of-5 has no way to learn it actually got something else. That
|
|
// is not hypothetical: luxfi/kms pkg/keys/manager.go validated req.Threshold
|
|
// and then stored `Threshold: result.Threshold`, which was always 0 because
|
|
// mpcd never sent the field. Every validator key set on record reads 0-of-0.
|
|
// Clients must compare these against what they requested and fail closed.
|
|
Threshold int `json:"threshold,omitempty"`
|
|
Participants []string `json:"participants,omitempty"`
|
|
}
|
|
|
|
type SignResult struct {
|
|
R string `json:"r,omitempty"`
|
|
S string `json:"s,omitempty"`
|
|
// V is the ECDSA recovery id ("0" or "1") for secp256k1 signatures, empty
|
|
// for schemes without recovery (ed25519/FROST). Signature carries the
|
|
// canonical ecrecover-ready blob r‖s‖v (65 bytes) when recoverable.
|
|
V string `json:"v,omitempty"`
|
|
Signature string `json:"signature,omitempty"`
|
|
}
|
|
|
|
type ClusterStatus struct {
|
|
NodeID string `json:"node_id"`
|
|
Mode string `json:"mode"`
|
|
ExpectedPeers int `json:"expected_peers"`
|
|
ConnectedPeers int `json:"connected_peers"`
|
|
Ready bool `json:"ready"`
|
|
Threshold int `json:"threshold"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
// HSMProvider abstracts hardware security module operations for co-signing.
|
|
type HSMProvider interface {
|
|
Sign(ctx context.Context, keyID string, message []byte) ([]byte, error)
|
|
Verify(ctx context.Context, keyID string, message, signature []byte) (bool, error)
|
|
}
|
|
|
|
type Server struct {
|
|
db *db.Database
|
|
mpc MPCBackend
|
|
hsm HSMProvider // optional: server-side HSM co-signing
|
|
tradeApproval *custody.TradeApprovalService
|
|
txTracker *txtracker.Tracker
|
|
jwtSecret []byte
|
|
oidcIssuers []string
|
|
webauthnRPID string
|
|
webauthnOrigins map[string]bool
|
|
// livenessVerifier validates the liveness attestation attached to
|
|
// /v1/mpc/biometric/enroll. Resolved at boot from the configured
|
|
// provider id: a white-label-registered Verifier if present, else the
|
|
// OSS-default Ed25519 envelope verifier built from the configured
|
|
// public key. Nil disables the endpoint (fails closed).
|
|
livenessVerifier webauthn.Verifier
|
|
livenessProviderID string
|
|
// livenessBindingMode controls the R3-8 envelope→enrollment binding.
|
|
// BindingStrict (default) rejects envelopes that don't carry
|
|
// credentialHash/challengeId. BindingLax logs a warning instead —
|
|
// intended only for the liveness provider extended-envelope rollout window.
|
|
// Configured via MPC_LIVENESS_BINDING=strict|lax (default strict).
|
|
livenessBindingMode webauthn.BindingMode
|
|
router chi.Router
|
|
replayGuard *replayGuard
|
|
|
|
// approval is the executive-approval registry consumed by /v1/approval/*.
|
|
// Optional — when nil, those endpoints return 503. Wired via SetApproval.
|
|
approval *ApprovalRegistry
|
|
|
|
// tieredWallets is the tiered-wallet registry consumed by /v1/wallets/*.
|
|
// Lazily initialized when nil; wired via SetTieredWalletRegistry.
|
|
tieredWallets wallet.Registry
|
|
|
|
// Events is the broadcast channel for server-side events (intent status,
|
|
// signing progress, wallet events). Handlers publish here; WebSocket
|
|
// clients and ZAP subscribers receive.
|
|
Events *EventBus
|
|
}
|
|
|
|
// maxBodySize returns middleware that limits request body size to prevent OOM
|
|
// from oversized payloads. Handlers that read beyond the limit get an error
|
|
// from http.MaxBytesReader.
|
|
func maxBodySize(maxBytes int64) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, oidcIssuers ...string) *Server {
|
|
// Default allowed issuers if none provided
|
|
if len(oidcIssuers) == 0 {
|
|
oidcIssuers = []string{
|
|
"https://hanzo.id",
|
|
"https://lux.id",
|
|
}
|
|
}
|
|
|
|
// WebAuthn RP ID — configurable via env, default lux.network
|
|
rpID := os.Getenv("MPC_WEBAUTHN_RP_ID")
|
|
if rpID == "" {
|
|
rpID = "lux.network"
|
|
}
|
|
|
|
// WebAuthn allowed origins — configurable via env (comma-separated)
|
|
webauthnOrigins := map[string]bool{}
|
|
if raw := os.Getenv("MPC_WEBAUTHN_ORIGINS"); raw != "" {
|
|
for _, o := range strings.Split(raw, ",") {
|
|
o = strings.TrimSpace(o)
|
|
if o != "" {
|
|
webauthnOrigins[o] = true
|
|
}
|
|
}
|
|
}
|
|
// Default WebAuthn origins — tenants add their own via MPC_WEBAUTHN_ORIGINS env.
|
|
for _, o := range []string{
|
|
"https://lux.network",
|
|
"https://mpc.lux.network",
|
|
} {
|
|
webauthnOrigins[o] = true
|
|
}
|
|
|
|
// Transaction lifecycle tracker (RPC clients added via SetTxTrackerRPC)
|
|
tracker := txtracker.New(txtracker.Config{
|
|
Database: database,
|
|
})
|
|
|
|
// Liveness attestation verifier. Resolve the active Verifier from the
|
|
// configured provider id:
|
|
//
|
|
// MPC_LIVENESS_PROVIDER_ID — provider id; also the registry key a
|
|
// white-label deployment registers under
|
|
// MPC_LIVENESS_PUBKEY_ED25519 — base64-standard Ed25519 public key for
|
|
// the OSS-default envelope verifier
|
|
//
|
|
// A deployment that registers its own webauthn.Verifier under the
|
|
// provider id (e.g. a regulated broker wiring its own PAD-2 vendor) wins;
|
|
// otherwise, if a public key is configured, the OSS-default Ed25519
|
|
// envelope verifier is used. With neither, livenessVerifier stays nil
|
|
// and /v1/mpc/biometric/enroll refuses every request — an unconfigured
|
|
// verifier MUST NOT fall back to trusting body-supplied scores.
|
|
livenessProviderID := os.Getenv("MPC_LIVENESS_PROVIDER_ID")
|
|
var livenessVerifier webauthn.Verifier
|
|
if v, ok := webauthn.LookupVerifier(livenessProviderID); ok {
|
|
livenessVerifier = v
|
|
} else if raw := os.Getenv("MPC_LIVENESS_PUBKEY_ED25519"); raw != "" {
|
|
if dec, err := base64.StdEncoding.DecodeString(raw); err == nil && len(dec) == ed25519.PublicKeySize {
|
|
livenessVerifier = webauthn.Ed25519EnvelopeVerifier{
|
|
PubKey: ed25519.PublicKey(dec),
|
|
ProviderID: livenessProviderID,
|
|
}
|
|
}
|
|
}
|
|
|
|
// R3-8 binding mode. Default STRICT; ops may toggle to LAX during a
|
|
// vendor's extended-envelope rollout. LAX emits a warn log on every
|
|
// permissive accept so rollout progress is observable.
|
|
bindingMode := webauthn.BindingStrict
|
|
if strings.ToLower(strings.TrimSpace(os.Getenv("MPC_LIVENESS_BINDING"))) == "lax" {
|
|
bindingMode = webauthn.BindingLax
|
|
}
|
|
|
|
s := &Server{
|
|
db: database,
|
|
mpc: mpcBackend,
|
|
txTracker: tracker,
|
|
jwtSecret: []byte(jwtSecret),
|
|
oidcIssuers: oidcIssuers,
|
|
webauthnRPID: rpID,
|
|
webauthnOrigins: webauthnOrigins,
|
|
livenessVerifier: livenessVerifier,
|
|
livenessProviderID: livenessProviderID,
|
|
livenessBindingMode: bindingMode,
|
|
replayGuard: newReplayGuard(),
|
|
Events: NewEventBus(),
|
|
}
|
|
|
|
// CORS origins — Lux infrastructure only. Tenants add via MPC_CORS_ORIGINS env.
|
|
corsOrigins := []string{
|
|
"https://cloud.lux.network",
|
|
"https://mpc.lux.network",
|
|
"https://bridge.lux.network",
|
|
"http://localhost:3000",
|
|
}
|
|
if raw := os.Getenv("MPC_CORS_ORIGINS"); raw != "" {
|
|
for _, o := range strings.Split(raw, ",") {
|
|
o = strings.TrimSpace(o)
|
|
if o != "" {
|
|
corsOrigins = append(corsOrigins, o)
|
|
}
|
|
}
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(chimw.RequestID)
|
|
r.Use(chimw.RealIP)
|
|
r.Use(chimw.Recoverer)
|
|
r.Use(maxBodySize(1 << 20)) // 1 MB
|
|
r.Use(chimw.Timeout(120 * time.Second))
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: corsOrigins,
|
|
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
|
ExposedHeaders: []string{"Link"},
|
|
AllowCredentials: true,
|
|
MaxAge: 300,
|
|
}))
|
|
r.Use(RateLimitMiddleware(100))
|
|
|
|
// Landing page, and the two assets it names. See landing.go.
|
|
r.Get("/", landing)
|
|
r.Get("/favicon.svg", favicon)
|
|
r.Get("/favicon.ico", ico)
|
|
r.Get("/fonts/Zen-Variable.woff2", zen)
|
|
|
|
// Health check (public, outside /v1, for K8s probes)
|
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
|
|
// Public routes
|
|
r.Route("/v1", func(r chi.Router) {
|
|
// Auth (no middleware)
|
|
r.Post("/auth/register", s.handleRegister)
|
|
r.Post("/auth/login", s.handleLogin)
|
|
r.Post("/auth/refresh", s.handleRefresh)
|
|
r.Post("/auth/oidc", s.handleOIDCExchange)
|
|
|
|
// WebSocket — auth via query param ?token=<jwt> (headers not sent on WS upgrade)
|
|
r.Get("/ws", s.handleWebSocket)
|
|
|
|
// Public payment page
|
|
r.Get("/pay/{token}", s.handlePublicPay)
|
|
|
|
// Bridge signing endpoints (API key or JWT auth; API key needs "sign" permission)
|
|
// Tighter rate limit for signing: 20 RPM per IP (separate from global 100 RPM).
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.authMiddleware)
|
|
r.Use(requirePermission("sign"))
|
|
r.Use(RateLimitMiddleware(20))
|
|
r.Post("/generate_mpc_sig", s.handleBridgeSign)
|
|
r.Post("/complete", s.handleBridgeComplete)
|
|
})
|
|
|
|
// Authenticated routes
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.authMiddleware)
|
|
r.Use(s.auditMiddleware)
|
|
|
|
// MFA (any authenticated user)
|
|
r.Post("/auth/mfa/setup", s.handleMFASetup)
|
|
r.Post("/auth/mfa/verify", s.handleMFAVerify)
|
|
|
|
// Users & Teams — owner/admin only
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Get("/users", s.handleListUsers)
|
|
r.Post("/users", s.handleInviteUser)
|
|
r.Patch("/users/{id}", s.handleUpdateUser)
|
|
r.Delete("/users/{id}", s.handleDeleteUser)
|
|
})
|
|
|
|
// API Keys — owner/admin only
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Get("/api-keys", s.handleListAPIKeys)
|
|
r.Post("/api-keys", s.handleCreateAPIKey)
|
|
r.Delete("/api-keys/{id}", s.handleDeleteAPIKey)
|
|
})
|
|
|
|
// Vaults — all authenticated; mutations require admin+
|
|
r.Get("/vaults", s.handleListVaults)
|
|
r.Get("/vaults/{id}", s.handleGetVault)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Post("/vaults", s.handleCreateVault)
|
|
r.Patch("/vaults/{id}", s.handleUpdateVault)
|
|
r.Delete("/vaults/{id}", s.handleDeleteVault)
|
|
})
|
|
|
|
// Wallets — keygen/reshare require admin+; sign requires signer+
|
|
r.Get("/vaults/{id}/wallets", s.handleListWallets)
|
|
r.Get("/wallets/{id}", s.handleGetWallet)
|
|
r.Get("/wallets/{id}/addresses", s.handleGetWalletAddresses)
|
|
r.Get("/wallets/{id}/history", s.handleWalletHistory)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Post("/vaults/{id}/wallets", s.handleCreateWallet)
|
|
r.Post("/wallets/{id}/reshare", s.handleReshareWallet)
|
|
})
|
|
|
|
// Transactions (legacy create/approve/reject plumbing kept as
|
|
// it is the substrate for the unified /v1/mpc/operations view).
|
|
// The read surface for operations is /v1/mpc/operations below;
|
|
// direct POST /v1/transactions retained as internal entry point
|
|
// for intents/payments which wire their own flows.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:transactions:create"))
|
|
r.Post("/transactions", s.handleCreateTransaction)
|
|
})
|
|
|
|
// Whitelist — owner/admin only
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Get("/whitelist", s.handleListWhitelist)
|
|
r.Post("/whitelist", s.handleAddWhitelist)
|
|
r.Delete("/whitelist/{id}", s.handleDeleteWhitelist)
|
|
})
|
|
|
|
// Webhooks — owner/admin only
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Get("/webhooks", s.handleListWebhooks)
|
|
r.Post("/webhooks", s.handleCreateWebhook)
|
|
r.Patch("/webhooks/{id}", s.handleUpdateWebhook)
|
|
r.Delete("/webhooks/{id}", s.handleDeleteWebhook)
|
|
r.Post("/webhooks/{id}/test", s.handleTestWebhook)
|
|
})
|
|
|
|
// Subscriptions — signer+ to create/pay; viewers can read
|
|
r.Get("/subscriptions", s.handleListSubscriptions)
|
|
r.Get("/subscriptions/{id}", s.handleGetSubscription)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:subscriptions:write"))
|
|
r.Post("/subscriptions", s.handleCreateSubscription)
|
|
r.Patch("/subscriptions/{id}", s.handleUpdateSubscription)
|
|
r.Delete("/subscriptions/{id}", s.handleDeleteSubscription)
|
|
r.Post("/subscriptions/{id}/pay-now", s.handlePayNow)
|
|
})
|
|
|
|
// Payment Requests — signer+ to create/pay
|
|
r.Get("/payment-requests", s.handleListPaymentRequests)
|
|
r.Get("/payment-requests/{id}", s.handleGetPaymentRequest)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:payment-requests:write"))
|
|
r.Post("/payment-requests", s.handleCreatePaymentRequest)
|
|
r.Post("/payment-requests/{id}/pay", s.handlePayPaymentRequest)
|
|
})
|
|
|
|
// Smart Wallets — admin+ to deploy; signer+ to propose/execute
|
|
r.Get("/wallets/{id}/smart-wallets", s.handleListSmartWallets)
|
|
r.Get("/smart-wallets/{id}", s.handleGetSmartWallet)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Post("/wallets/{id}/smart-wallet", s.handleDeploySmartWallet)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:smart-wallets:write"))
|
|
r.Post("/smart-wallets/{id}/propose", s.handleProposeSafeTx)
|
|
r.Post("/smart-wallets/{id}/execute", s.handleExecuteSafeTx)
|
|
r.Post("/smart-wallets/{id}/user-operation", s.handleUserOperation)
|
|
})
|
|
|
|
// Bridge admin — owner/admin only
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Get("/bridge/config", s.handleGetBridgeConfig)
|
|
r.Patch("/bridge/config", s.handleUpdateBridgeConfig)
|
|
r.Get("/bridge/networks", s.handleListBridgeNetworks)
|
|
})
|
|
|
|
// WebAuthn/Biometric — any authenticated user can register devices and approve with biometrics
|
|
r.Post("/webauthn/register/begin", s.handleRegisterWebAuthnBegin)
|
|
r.Post("/webauthn/register/complete", s.handleRegisterWebAuthnComplete)
|
|
r.Post("/webauthn/verify", s.handleVerifyWebAuthn) // Biometric approval of transactions
|
|
r.Get("/webauthn/credentials", s.handleListWebAuthnCredentials)
|
|
r.Delete("/webauthn/credentials/{id}", s.handleDeleteWebAuthnCredential)
|
|
|
|
// Trade Approval — push notification + biometric signing flow
|
|
// Submit requires signer+ (ATS service account); approve/reject require the trade owner
|
|
r.Get("/trade/pending", s.handlePendingTrades)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:trade:write"))
|
|
r.Post("/trade/submit", s.handleTradeSubmit)
|
|
r.Post("/trade/approve", s.handleTradeApprove)
|
|
r.Post("/trade/reject", s.handleTradeReject)
|
|
})
|
|
|
|
// Intents & Settlements — signer+ can create; viewers can read
|
|
r.Get("/intents", s.handleListIntents)
|
|
r.Get("/intents/{id}", s.handleGetIntent)
|
|
r.Get("/settlements", s.handleListSettlements)
|
|
r.Get("/settlements/{id}", s.handleGetSettlement)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:sign"))
|
|
r.Use(RateLimitMiddleware(20)) // signing rate limit: 20 RPM per IP
|
|
r.Post("/intents", s.handleCreateIntent)
|
|
r.Post("/intents/{id}/sign", s.handleSignIntent)
|
|
r.Post("/intents/{id}/co-sign", s.handleCoSignIntent)
|
|
})
|
|
|
|
// Wallet Backup — admin+ to create; signer+ to recover
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Post("/wallets/{id}/backup", s.handleCreateWalletBackup)
|
|
r.Get("/wallets/{id}/backup", s.handleGetWalletBackup)
|
|
})
|
|
|
|
// Legacy audit route removed — moved to /v1/mpc/audit.
|
|
|
|
// Validator key management (KMS) — owner/admin only
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
s.registerKMSRoutes(r)
|
|
})
|
|
|
|
// Status — any authenticated (including API keys)
|
|
r.Get("/status", s.handleStatus)
|
|
r.Get("/info", s.handleInfo)
|
|
|
|
// ── /v1/mpc/* — MPC spec surface
|
|
// Canonical routes defined in the MPC OpenAPI spec.
|
|
// No alias paths, no backwards compatibility.
|
|
r.Route("/mpc", func(r chi.Router) {
|
|
// Wallets
|
|
r.Get("/wallets", s.handleMpcListWallets)
|
|
r.Post("/wallets", s.handleMpcCreateWallet)
|
|
r.Get("/wallets/balances", s.handleMpcWalletBalances)
|
|
r.Post("/wallets/sweep", s.handleMpcSweepWallet)
|
|
r.Get("/wallets/{id}", s.handleMpcGetWallet)
|
|
r.Patch("/wallets/{id}/default", s.handleMpcSetDefaultWallet)
|
|
|
|
// Default wallet
|
|
r.Get("/wallet", s.handleMpcGetDefaultWallet)
|
|
r.Post("/wallet", s.handleMpcCreateDefaultWallet)
|
|
|
|
// Balances + crypto
|
|
r.Get("/balances/{address}", s.handleMpcBalancesByAddress)
|
|
r.Get("/crypto/wallet/{asset}", s.handleMpcCryptoWallet)
|
|
|
|
// Signing — R2-3: "api" role must additionally carry the
|
|
// concrete permission; a bare API key with permissions=[] is
|
|
// no longer sufficient.
|
|
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:sign"), RateLimitMiddleware(20)).
|
|
Post("/sign", s.handleMpcSignDefault)
|
|
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:settlement:sign"), RateLimitMiddleware(20)).
|
|
Post("/settlement/sign", s.handleMpcSignSettlement)
|
|
|
|
// WebAuthn (spec-named aliases to the existing handlers)
|
|
r.Post("/webauthn/challenge", s.handleRegisterWebAuthnBegin)
|
|
r.Post("/webauthn/verify", s.handleVerifyWebAuthn)
|
|
|
|
// Biometric
|
|
r.Post("/biometric/enroll", s.handleBiometricEnroll)
|
|
r.Get("/biometric/status", s.handleBiometricStatus)
|
|
|
|
// Sessions (wallet-scoped)
|
|
r.Route("/wallets/{id}/sessions", func(r chi.Router) {
|
|
r.Get("/", s.handleListWalletSessions)
|
|
r.Post("/", s.handleCreateWalletSession)
|
|
r.Get("/{sessionId}", s.handleGetWalletSession)
|
|
r.Delete("/{sessionId}", s.handleRevokeWalletSession)
|
|
})
|
|
|
|
// Operations (unified view over Transaction)
|
|
r.Get("/operations", s.handleListOperations)
|
|
r.Get("/operations/{operationId}", s.handleGetOperation)
|
|
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:operations:approve")).
|
|
Post("/operations/{operationId}/approve", s.handleApproveOperation)
|
|
r.With(requireRoleOrAPIPermission([]string{"owner", "admin", "signer"}, "mpc:operations:approve")).
|
|
Post("/operations/{operationId}/reject", s.handleRejectOperation)
|
|
|
|
// Policies — owner/admin
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRole("owner", "admin"))
|
|
r.Get("/policies", s.handleListPolicies)
|
|
r.Post("/policies", s.handleCreatePolicy)
|
|
r.Get("/policies/{policyId}", s.handleGetPolicy)
|
|
r.Patch("/policies/{policyId}", s.handleUpdatePolicy)
|
|
r.Delete("/policies/{policyId}", s.handleDeletePolicy)
|
|
})
|
|
|
|
// Audit — owner/admin
|
|
r.With(requireRole("owner", "admin")).Get("/audit", s.handleMpcListAudit)
|
|
|
|
// Treasury — 3-of-5 org governance surface.
|
|
// wallet CRUD requires owner/admin role OR API key with
|
|
// mpc.treasury.admin permission. Sign requires a treasury
|
|
// signer (principal KeyRef must appear in the signer set).
|
|
r.Route("/treasury", func(r chi.Router) {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requireRoleOrAPIPermission(
|
|
[]string{"owner", "admin"}, treasuryAdminPermission))
|
|
r.Post("/wallets", s.handleCreateTreasuryWallet)
|
|
r.Get("/wallets", s.handleListTreasuryWallets)
|
|
r.Get("/wallets/{id}", s.handleGetTreasuryWallet)
|
|
r.Post("/wallets/{id}/rotate-signer", s.handleRotateTreasurySigner)
|
|
})
|
|
r.With(requireRoleOrAPIPermission(
|
|
[]string{"owner", "admin", "signer"}, treasurySignPermission)).
|
|
Post("/sign", s.handleTreasurySign)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
// KMS routes are registered INSIDE the authenticated route group above.
|
|
// See registerKMSRoutes — it applies requireRole("owner", "admin").
|
|
|
|
// Start background intent expiry reaper
|
|
s.StartIntentReaper(context.Background(), 5*time.Minute)
|
|
|
|
// Start background trade expiry reaper (60s interval — trades expire in 5 min)
|
|
s.StartTradeReaper(context.Background(), 60*time.Second)
|
|
|
|
s.router = r
|
|
|
|
return s
|
|
}
|
|
|
|
// SetHSM configures the server-side HSM provider for intent co-signing.
|
|
// When set, the co-sign endpoint signs with the HSM directly instead of
|
|
// accepting client-submitted signatures (prevents forged co-signatures).
|
|
func (s *Server) SetHSM(hsm HSMProvider) {
|
|
s.hsm = hsm
|
|
}
|
|
|
|
// SetTradeApproval configures the trade approval service for push notification
|
|
// and biometric approval flow. When set, the /v1/trade endpoints are active.
|
|
func (s *Server) SetTradeApproval(notifier custody.PushNotifier) {
|
|
s.tradeApproval = custody.NewTradeApprovalService(s.db.ORM, notifier)
|
|
}
|
|
|
|
// Handler returns the underlying http.Handler for mounting on external servers (e.g. Hanzo Base).
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.router
|
|
}
|