mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
858 lines
28 KiB
Go
858 lines
28 KiB
Go
// Package api — MPC spec handlers (Liquidity /v1/mpc surface).
|
|
//
|
|
// This file implements the spec-shaped endpoints defined in
|
|
// `~/work/liquidity/openapi/mpc.yaml`. Legacy paths (/v1/transactions,
|
|
// /v1/policies, /v1/audit) are superseded by their /v1/mpc/* equivalents.
|
|
//
|
|
// The canonical `Operation` view (unified across sign/send/mint/burn/transfer
|
|
// /contract_call) maps onto the existing `db.Transaction` table via the
|
|
// `txType` discriminator. No new operations table — one table, one view.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/orm"
|
|
|
|
"github.com/luxfi/mpc/pkg/db"
|
|
"github.com/luxfi/mpc/pkg/webauthn"
|
|
)
|
|
|
|
// logBiometricBindingWarning records a structured warning when the R3-8
|
|
// envelope→enrollment binding was weak (e.g. has not yet shipped
|
|
// the extended envelope and the server is running in LAX mode). Never fires
|
|
// in STRICT mode — those calls are rejected outright.
|
|
func (s *Server) logBiometricBindingWarning(orgID, userID, msg string) {
|
|
log.Printf("mpc.biometric.binding_warn org=%s user=%s msg=%q", orgID, userID, msg)
|
|
}
|
|
|
|
var errUnknownEncoding = errors.New("unknown encoding (must be hex|base64)")
|
|
|
|
func hexDecodeSafe(s string) ([]byte, error) {
|
|
s = strings.TrimPrefix(s, "0x")
|
|
return hex.DecodeString(s)
|
|
}
|
|
|
|
func base64DecodeSafe(s string) ([]byte, error) {
|
|
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
|
|
return b, nil
|
|
}
|
|
return base64.RawURLEncoding.DecodeString(s)
|
|
}
|
|
|
|
// --- Shared audit helper used by MPC surface handlers ---
|
|
|
|
// recordMpcAudit writes a typed MPC-scoped audit entry. Uses the existing
|
|
// db.AuditEntry table; the `action` carries the structured verb
|
|
// (session.create, operation.approve, policy.update, etc.) per spec.
|
|
func (s *Server) recordMpcAudit(
|
|
ctx context.Context, orgID, userID, action, subjectType, subjectID string,
|
|
) {
|
|
if orgID == "" {
|
|
return
|
|
}
|
|
e := orm.New[db.AuditEntry](s.db.ORM)
|
|
e.OrgID = orgID
|
|
e.UserID = nilIfEmpty(userID)
|
|
e.Action = action
|
|
e.ResourceType = nilIfEmpty(subjectType)
|
|
e.ResourceID = nilIfEmpty(subjectID)
|
|
e.Create()
|
|
}
|
|
|
|
// --- /v1/mpc/wallets — spec-shape wallet list/create ---
|
|
|
|
// walletResponse matches the OpenAPI `Wallet` schema.
|
|
type walletResponse struct {
|
|
WalletID string `json:"walletId"`
|
|
Address string `json:"address"`
|
|
Status string `json:"status"`
|
|
Protocol string `json:"protocol"`
|
|
Threshold string `json:"threshold"`
|
|
IsDefault bool `json:"isDefault"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
func toWalletResponse(w *db.Wallet, isDefault bool) walletResponse {
|
|
addr := ""
|
|
if w.EVMAddress != nil {
|
|
addr = *w.EVMAddress
|
|
} else if w.BtcAddress != nil {
|
|
addr = *w.BtcAddress
|
|
} else if w.SolAddress != nil {
|
|
addr = *w.SolAddress
|
|
}
|
|
status := w.Status
|
|
if status == "" {
|
|
status = "active"
|
|
}
|
|
threshold := strconv.Itoa(w.Threshold) + "-of-" + strconv.Itoa(len(w.Participants))
|
|
return walletResponse{
|
|
WalletID: w.WalletID,
|
|
Address: addr,
|
|
Status: status,
|
|
Protocol: w.Protocol,
|
|
Threshold: threshold,
|
|
IsDefault: isDefault,
|
|
CreatedAt: w.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleMpcListWallets(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
role := getRole(r.Context())
|
|
|
|
q := orm.TypedQuery[db.Wallet](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Order("-createdAt").
|
|
Limit(500)
|
|
|
|
if statusFilter := r.URL.Query().Get("status"); statusFilter != "" {
|
|
q = q.Filter("status=", statusFilter)
|
|
}
|
|
if role != "owner" && role != "admin" && userID != "" {
|
|
q = q.Filter("createdBy=", userID)
|
|
}
|
|
wallets, err := q.GetAll(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
defaultID := s.findDefaultWalletID(r.Context(), orgID, userID)
|
|
items := make([]walletResponse, 0, len(wallets))
|
|
for _, wal := range wallets {
|
|
items = append(items, toWalletResponse(wal, wal.Id() == defaultID))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"items": items,
|
|
"page": 1,
|
|
"totalItems": len(items),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleMpcCreateWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
|
|
var req struct {
|
|
Protocol string `json:"protocol"`
|
|
Threshold string `json:"threshold"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if req.Protocol == "" {
|
|
req.Protocol = "cggmp21"
|
|
}
|
|
if req.Protocol != "cggmp21" && req.Protocol != "frost" {
|
|
writeError(w, http.StatusBadRequest, "protocol must be cggmp21 or frost")
|
|
return
|
|
}
|
|
|
|
result, err := s.mpc.TriggerKeygen(orgID, "")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "keygen failed: "+err.Error())
|
|
return
|
|
}
|
|
status := s.mpc.GetClusterStatus()
|
|
participants := []string{}
|
|
if status != nil {
|
|
for i := 0; i < status.ExpectedPeers+1; i++ {
|
|
participants = append(participants, "node"+strconv.Itoa(i))
|
|
}
|
|
}
|
|
|
|
wal := orm.New[db.Wallet](s.db.ORM)
|
|
wal.OrgID = orgID
|
|
wal.WalletID = result.WalletID
|
|
wal.KeyType = "secp256k1"
|
|
wal.Protocol = req.Protocol
|
|
wal.ECDSAPubkey = nilIfEmpty(result.ECDSAPubKey)
|
|
wal.EDDSAPubkey = nilIfEmpty(result.EDDSAPubKey)
|
|
wal.EVMAddress = nilIfEmpty(result.EVMAddress)
|
|
wal.BtcAddress = nilIfEmpty(result.BtcAddress)
|
|
wal.SolAddress = nilIfEmpty(result.SolAddress)
|
|
if status != nil {
|
|
wal.Threshold = status.Threshold
|
|
}
|
|
wal.Participants = participants
|
|
wal.Version = 1
|
|
wal.Status = "active"
|
|
wal.CreatedBy = nilIfEmpty(userID)
|
|
if err := wal.Create(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to save wallet: "+err.Error())
|
|
return
|
|
}
|
|
s.recordMpcAudit(r.Context(), orgID, userID, "wallet.create", "wallet", wal.Id())
|
|
writeJSON(w, http.StatusCreated, toWalletResponse(wal, false))
|
|
}
|
|
|
|
func (s *Server) handleMpcGetWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
id := urlParam(r, "id")
|
|
wal, err := orm.Get[db.Wallet](s.db.ORM, id)
|
|
if err != nil || wal.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
defaultID := s.findDefaultWalletID(r.Context(), orgID, userID)
|
|
writeJSON(w, http.StatusOK, toWalletResponse(wal, wal.Id() == defaultID))
|
|
}
|
|
|
|
// --- Default wallet per user ---
|
|
//
|
|
// findDefaultWalletID returns the wallet explicitly marked as default for the
|
|
// given user (via `defaultForUserId`). There is at most one; if none is
|
|
// marked, returns "" — callers must then handle the no-default case. Never
|
|
// returns "the most recently created wallet" as a fallback — that was the old
|
|
// behavior and was semantically ambiguous (F22).
|
|
func (s *Server) findDefaultWalletID(
|
|
ctx context.Context, orgID, userID string,
|
|
) string {
|
|
if userID == "" {
|
|
return ""
|
|
}
|
|
wallets, err := orm.TypedQuery[db.Wallet](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Filter("defaultForUserId=", userID).
|
|
Limit(1).
|
|
GetAll(ctx)
|
|
if err != nil || len(wallets) == 0 {
|
|
return ""
|
|
}
|
|
return wallets[0].Id()
|
|
}
|
|
|
|
// handleMpcSetDefaultWallet marks a wallet as default for the calling user.
|
|
// Transactionally unsets `defaultForUserId` on any previously-default wallet
|
|
// for this (orgId, userId) before setting it on the new one. Does NOT touch
|
|
// the wallet's Name field (that's user-supplied metadata, not a control
|
|
// channel) — F14.
|
|
func (s *Server) handleMpcSetDefaultWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
id := urlParam(r, "id")
|
|
if userID == "" {
|
|
writeError(w, http.StatusUnauthorized, "authenticated user required")
|
|
return
|
|
}
|
|
|
|
// Verify target wallet belongs to the org
|
|
wal, err := orm.Get[db.Wallet](s.db.ORM, id)
|
|
if err != nil || wal.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
|
|
// Clear any previous default for this user, then set the new one.
|
|
// We do this via explicit query + per-record update (ORM has no bulk
|
|
// Update) but wrap in a single logical sequence. The worst case on a
|
|
// concurrent set is that two wallets end up marked default briefly; the
|
|
// read path picks Limit(1), so observable behavior remains consistent.
|
|
prev, _ := orm.TypedQuery[db.Wallet](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Filter("defaultForUserId=", userID).
|
|
GetAll(r.Context())
|
|
for _, p := range prev {
|
|
if p.Id() == id {
|
|
continue
|
|
}
|
|
p.DefaultForUserID = nil
|
|
_ = p.Update()
|
|
}
|
|
wal.DefaultForUserID = &userID
|
|
if err := wal.Update(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to set default wallet")
|
|
return
|
|
}
|
|
s.recordMpcAudit(r.Context(), orgID, userID, "wallet.set_default", "wallet", wal.Id())
|
|
writeJSON(w, http.StatusOK, toWalletResponse(wal, true))
|
|
}
|
|
|
|
func (s *Server) handleMpcGetDefaultWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
id := s.findDefaultWalletID(r.Context(), orgID, userID)
|
|
if id == "" {
|
|
writeError(w, http.StatusNotFound, "no default wallet")
|
|
return
|
|
}
|
|
wal, err := orm.Get[db.Wallet](s.db.ORM, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "no default wallet")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toWalletResponse(wal, true))
|
|
}
|
|
|
|
func (s *Server) handleMpcCreateDefaultWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
// If a default already exists, return it.
|
|
if id := s.findDefaultWalletID(r.Context(), orgID, userID); id != "" {
|
|
wal, _ := orm.Get[db.Wallet](s.db.ORM, id)
|
|
writeJSON(w, http.StatusOK, toWalletResponse(wal, true))
|
|
return
|
|
}
|
|
// Otherwise, delegate to the spec wallet-create flow.
|
|
s.handleMpcCreateWallet(w, r)
|
|
}
|
|
|
|
// --- /v1/mpc/wallets/sweep ---
|
|
|
|
func (s *Server) handleMpcSweepWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
|
|
var req struct {
|
|
FromWalletID string `json:"fromWalletId"`
|
|
ToAddress string `json:"toAddress"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.FromWalletID == "" || req.ToAddress == "" {
|
|
writeError(w, http.StatusBadRequest, "fromWalletId and toAddress are required")
|
|
return
|
|
}
|
|
wal, err := orm.Get[db.Wallet](s.db.ORM, req.FromWalletID)
|
|
if err != nil || wal.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
// Create a sweep operation (Transaction with txType=sweep). Returns the
|
|
// `MpcTransaction` shape the spec expects.
|
|
tx := orm.New[db.Transaction](s.db.ORM)
|
|
tx.OrgID = orgID
|
|
walletID := wal.Id()
|
|
tx.WalletID = &walletID
|
|
tx.TxType = "sweep"
|
|
tx.Chain = "evm"
|
|
tx.ToAddress = &req.ToAddress
|
|
tx.Status = "pending"
|
|
tx.InitiatedBy = nilIfEmpty(userID)
|
|
if err := tx.Create(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to initiate sweep")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
|
"txId": tx.Id(),
|
|
"status": tx.Status,
|
|
"createdAt": tx.CreatedAt,
|
|
})
|
|
}
|
|
|
|
// --- /v1/mpc/wallets/balances + /v1/mpc/balances/{address} ---
|
|
|
|
func (s *Server) handleMpcWalletBalances(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
role := getRole(r.Context())
|
|
|
|
q := orm.TypedQuery[db.Wallet](s.db.ORM).Filter("orgId=", orgID).Limit(200)
|
|
if role != "owner" && role != "admin" && userID != "" {
|
|
q = q.Filter("createdBy=", userID)
|
|
}
|
|
wallets, err := q.GetAll(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
items := make([]map[string]interface{}, 0, len(wallets))
|
|
for _, wal := range wallets {
|
|
addr := ""
|
|
if wal.EVMAddress != nil {
|
|
addr = *wal.EVMAddress
|
|
}
|
|
items = append(items, map[string]interface{}{
|
|
"walletId": wal.Id(),
|
|
"address": addr,
|
|
"balances": []any{},
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"wallets": items})
|
|
}
|
|
|
|
// handleMpcBalancesByAddress returns balances for a wallet addressed by its
|
|
// on-chain address. Address must belong to an MPC wallet in the caller's org,
|
|
// otherwise 404. This prevents address enumeration across tenants (F4).
|
|
func (s *Server) handleMpcBalancesByAddress(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
addr := urlParam(r, "address")
|
|
if addr == "" {
|
|
writeError(w, http.StatusBadRequest, "address is required")
|
|
return
|
|
}
|
|
|
|
// Try EVM, BTC, SOL address fields in turn. Any match scoped to
|
|
// caller's org resolves the address; otherwise 404 (never leak
|
|
// "address unknown globally" vs "address not in your org").
|
|
q := orm.TypedQuery[db.Wallet](s.db.ORM).Filter("orgId=", orgID).Limit(1)
|
|
wallets, _ := q.Filter("evmAddress=", addr).GetAll(r.Context())
|
|
if len(wallets) == 0 {
|
|
wallets, _ = orm.TypedQuery[db.Wallet](s.db.ORM).
|
|
Filter("orgId=", orgID).Filter("btcAddress=", addr).Limit(1).GetAll(r.Context())
|
|
}
|
|
if len(wallets) == 0 {
|
|
wallets, _ = orm.TypedQuery[db.Wallet](s.db.ORM).
|
|
Filter("orgId=", orgID).Filter("solAddress=", addr).Limit(1).GetAll(r.Context())
|
|
}
|
|
if len(wallets) == 0 {
|
|
writeError(w, http.StatusNotFound, "address not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"address": addr,
|
|
"walletId": wallets[0].Id(),
|
|
"balances": []any{},
|
|
})
|
|
}
|
|
|
|
// --- /v1/mpc/crypto/wallet/{asset} ---
|
|
|
|
func (s *Server) handleMpcCryptoWallet(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
asset := urlParam(r, "asset")
|
|
|
|
id := s.findDefaultWalletID(r.Context(), orgID, userID)
|
|
if id == "" {
|
|
writeError(w, http.StatusNotFound, "no wallet for asset")
|
|
return
|
|
}
|
|
wal, err := orm.Get[db.Wallet](s.db.ORM, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "no wallet for asset")
|
|
return
|
|
}
|
|
addr := ""
|
|
network := ""
|
|
switch asset {
|
|
case "btc", "BTC":
|
|
if wal.BtcAddress != nil {
|
|
addr = *wal.BtcAddress
|
|
}
|
|
network = "bitcoin"
|
|
case "sol", "SOL":
|
|
if wal.SolAddress != nil {
|
|
addr = *wal.SolAddress
|
|
}
|
|
network = "solana"
|
|
default:
|
|
if wal.EVMAddress != nil {
|
|
addr = *wal.EVMAddress
|
|
}
|
|
network = "ethereum"
|
|
}
|
|
if addr == "" {
|
|
writeError(w, http.StatusNotFound, "no wallet for asset")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"asset": asset,
|
|
"address": addr,
|
|
"network": network,
|
|
"walletId": wal.Id(),
|
|
})
|
|
}
|
|
|
|
// --- /v1/mpc/sign + /v1/mpc/settlement/sign ---
|
|
|
|
type signRequest struct {
|
|
Message string `json:"message"`
|
|
Encoding string `json:"encoding,omitempty"`
|
|
WalletID string `json:"walletId,omitempty"`
|
|
SessionID string `json:"sessionId,omitempty"`
|
|
Value string `json:"value,omitempty"`
|
|
}
|
|
|
|
// handleMpcSignDefault is the /v1/mpc/sign endpoint. It is an authenticated
|
|
// MPC signing oracle — so it enforces (F1 + F15):
|
|
// - Role gate at router (requireRole "owner","admin","signer","api")
|
|
// - Non-empty sessionId REQUIRED (no silent sign with implicit session)
|
|
// - Session must be active, bound to caller + wallet, have `sign` scope,
|
|
// and have remaining op/value budget — all enforced atomically via CAS
|
|
// in consumeSessionForSign.
|
|
// - Message encoding MUST decode successfully; no fallback to raw bytes.
|
|
func (s *Server) handleMpcSignDefault(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
|
|
var req signRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Message == "" {
|
|
writeError(w, http.StatusBadRequest, "message is required")
|
|
return
|
|
}
|
|
if req.SessionID == "" {
|
|
writeError(w, http.StatusForbidden, "sessionId is required for signing")
|
|
return
|
|
}
|
|
walletID := req.WalletID
|
|
if walletID == "" {
|
|
walletID = s.findDefaultWalletID(r.Context(), orgID, userID)
|
|
}
|
|
if walletID == "" {
|
|
writeError(w, http.StatusBadRequest, "no default wallet; specify walletId")
|
|
return
|
|
}
|
|
wal, err := orm.Get[db.Wallet](s.db.ORM, walletID)
|
|
if err != nil || wal.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
|
|
// Atomic session consume: increments operationsUsed + adds to valueAccum
|
|
// with CAS. Fails if session is expired, revoked, wrong principal, lacks
|
|
// `sign` scope, or limits would be exceeded.
|
|
if _, err := s.consumeSessionForSign(r.Context(), orgID, walletID, userID, req.SessionID, req.Value); err != nil {
|
|
writeHTTPError(w, err)
|
|
return
|
|
}
|
|
|
|
payload, err := decodeMessage(req.Message, req.Encoding)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid message encoding: "+err.Error())
|
|
return
|
|
}
|
|
result, err := s.mpc.TriggerSign(orgID, wal.WalletID, payload)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "signing failed: "+err.Error())
|
|
return
|
|
}
|
|
s.recordMpcAudit(r.Context(), orgID, userID, "operation.sign", "wallet", walletID)
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"signature": result.Signature,
|
|
"r": result.R,
|
|
"s": result.S,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleMpcSignSettlement(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
|
|
var req struct {
|
|
WalletID string `json:"walletId"`
|
|
Payload string `json:"payload"`
|
|
Reason string `json:"reason"`
|
|
ReferenceID string `json:"referenceId,omitempty"`
|
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
SessionID string `json:"sessionId,omitempty"`
|
|
Value string `json:"value,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.WalletID == "" || req.Payload == "" || req.Reason == "" {
|
|
writeError(w, http.StatusBadRequest, "walletId, payload, reason are required")
|
|
return
|
|
}
|
|
switch req.Reason {
|
|
case "trade_settlement", "deposit", "withdrawal", "issuance", "cancellation":
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid reason")
|
|
return
|
|
}
|
|
wal, err := orm.Get[db.Wallet](s.db.ORM, req.WalletID)
|
|
if err != nil || wal.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
if req.SessionID == "" {
|
|
writeError(w, http.StatusForbidden, "sessionId is required for settlement signing")
|
|
return
|
|
}
|
|
if _, err := s.consumeSessionForSign(r.Context(), orgID, req.WalletID, userID, req.SessionID, req.Value); err != nil {
|
|
writeHTTPError(w, err)
|
|
return
|
|
}
|
|
payload, err := decodeMessage(req.Payload, "hex")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid payload encoding: "+err.Error())
|
|
return
|
|
}
|
|
result, err := s.mpc.TriggerSign(orgID, wal.WalletID, payload)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "settlement signing failed: "+err.Error())
|
|
return
|
|
}
|
|
s.recordMpcAudit(r.Context(), orgID, userID, "operation.sign_settlement", "wallet", req.WalletID)
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"signature": result.Signature,
|
|
"r": result.R,
|
|
"s": result.S,
|
|
})
|
|
}
|
|
|
|
// --- /v1/mpc/biometric/{enroll,status} ---
|
|
|
|
// handleBiometricEnroll — biometric enrollment is NOT an "accept arbitrary
|
|
// template bytes" endpoint. R2-1 hardening:
|
|
//
|
|
// 1. The client must first call /v1/mpc/webauthn/challenge. The server
|
|
// generates random bytes, stores them on the WebAuthnCredential row, and
|
|
// sends both the row ID ("credentialId") and the base64url-encoded
|
|
// random challenge ("challenge") back. The attacker does not get a usable
|
|
// oracle on the row ID because we now compare against the random bytes,
|
|
// not the ID.
|
|
// 2. clientDataJSON origin is compared against the server origin allowlist.
|
|
// 3. authenticatorData[0:32] is compared against sha256(rpID); the UP and
|
|
// UV flags are enforced so locked authenticators are rejected.
|
|
// 4. Liveness score is NEVER accepted from the request body. Instead the
|
|
// client attaches a -signed Ed25519 attestation envelope
|
|
// over (providerId, userId, score, timestamp, nonce). The server verifies
|
|
// the signature against the configured pubkey and enforces
|
|
// userId match, score floor, and max age.
|
|
func (s *Server) handleBiometricEnroll(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
|
|
if len(s.PubKey) == 0 {
|
|
writeError(w, http.StatusServiceUnavailable, "biometric enrollment unavailable: liveness verifier not configured")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
CredentialID string `json:"credentialId"`
|
|
SignedResponse string `json:"signedResponse"`
|
|
AuthenticatorData string `json:"authenticatorData"`
|
|
ClientDataJSON string `json:"clientDataJSON"`
|
|
PublicKey string `json:"publicKey"`
|
|
Modality string `json:"modality"`
|
|
LivenessAttestation string `json:"livenessAttestation"` // base64 envelope
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.CredentialID == "" || req.SignedResponse == "" || req.PublicKey == "" ||
|
|
req.AuthenticatorData == "" || req.ClientDataJSON == "" {
|
|
writeError(w, http.StatusBadRequest, "credentialId, signedResponse, authenticatorData, clientDataJSON, publicKey required")
|
|
return
|
|
}
|
|
if req.LivenessAttestation == "" {
|
|
writeError(w, http.StatusBadRequest, "livenessAttestation is required")
|
|
return
|
|
}
|
|
if req.Modality == "" {
|
|
req.Modality = "face"
|
|
}
|
|
if req.Modality != "face" && req.Modality != "fingerprint" {
|
|
writeError(w, http.StatusBadRequest, "modality must be face or fingerprint")
|
|
return
|
|
}
|
|
|
|
// Atomically consume the WebAuthnCredential challenge row and capture the
|
|
// random challenge bytes we sent. We do this BEFORE liveness verification
|
|
// so we have the challenge bytes to bind the attestation against (R3-8).
|
|
chalBytes, ok, err := s.consumeBiometricChallenge(r.Context(), orgID, userID, req.CredentialID)
|
|
if err != nil || !ok {
|
|
writeError(w, http.StatusForbidden, "invalid or expired challenge")
|
|
return
|
|
}
|
|
|
|
// R3-8: compute the credentialHash and challengeID the envelope must
|
|
// bind to. signs these into the attestation so a stolen
|
|
// envelope cannot be replayed to enroll an attacker-controlled key.
|
|
pubKeyBytes, decErr := base64.StdEncoding.DecodeString(req.PublicKey)
|
|
if decErr != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid publicKey encoding")
|
|
return
|
|
}
|
|
credentialHashRaw := sha256.Sum256(pubKeyBytes)
|
|
expectedCredentialHash := base64.StdEncoding.EncodeToString(credentialHashRaw[:])
|
|
expectedChallengeID := base64.RawURLEncoding.EncodeToString(chalBytes)
|
|
|
|
// Verify the server-signed liveness attestation. Fails closed.
|
|
// Binding mode default = Strict; an operator may relax to Lax during
|
|
// the rollout window by setting MPC_LIVENESS_BINDING=lax.
|
|
// The relaxed mode still warns on every call so the rollout progress
|
|
// is observable in logs.
|
|
const minLivenessScore = 0.8
|
|
if _, err := webauthn.VerifyLiveness(req.LivenessAttestation, &webauthn.LivenessOpts{
|
|
PubKey: s.PubKey,
|
|
ProviderID: s.ProviderID,
|
|
UserID: userID,
|
|
MinScore: minLivenessScore,
|
|
MaxAge: 2 * time.Minute,
|
|
ExpectedCredentialHash: expectedCredentialHash,
|
|
ExpectedChallengeID: expectedChallengeID,
|
|
BindingMode: s.livenessBindingMode,
|
|
WarnFn: func(msg string) {
|
|
s.logBiometricBindingWarning(orgID, userID, msg)
|
|
},
|
|
}); err != nil {
|
|
writeError(w, http.StatusForbidden, "liveness attestation rejected")
|
|
return
|
|
}
|
|
|
|
// Verify the WebAuthn ceremony against:
|
|
// - the random challenge bytes we issued (not the DB row ID),
|
|
// - the origin allowlist,
|
|
// - the rpID hash,
|
|
// - UP+UV flags.
|
|
if _, err := webauthn.Verify(&webauthn.Opts{
|
|
Ceremony: webauthn.CeremonyCreate,
|
|
ExpectedChallenge: chalBytes,
|
|
AllowedOrigins: s.webauthnOrigins,
|
|
RPID: s.webauthnRPID,
|
|
RequireUP: true,
|
|
RequireUV: true,
|
|
PublicKeyB64: req.PublicKey,
|
|
ClientDataJSONB64: req.ClientDataJSON,
|
|
AuthDataB64: req.AuthenticatorData,
|
|
SignatureB64: req.SignedResponse,
|
|
}); err != nil {
|
|
writeError(w, http.StatusForbidden, "attestation verification failed")
|
|
return
|
|
}
|
|
|
|
enroll := orm.New[db.DeviceEnrollment](s.db.ORM)
|
|
enroll.OrgID = orgID
|
|
enroll.UserID = userID
|
|
enroll.DeviceID = "biometric-" + req.Modality
|
|
enroll.DeviceType = "biometric"
|
|
enroll.BiometricType = req.Modality
|
|
// Store the attested PUBLIC KEY only — never the raw template.
|
|
enroll.PublicKey = req.PublicKey
|
|
enroll.Status = "active"
|
|
if err := enroll.Create(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to enroll biometric")
|
|
return
|
|
}
|
|
s.recordMpcAudit(r.Context(), orgID, userID, "biometric.enroll", "biometric", enroll.Id())
|
|
writeJSON(w, http.StatusCreated, map[string]string{
|
|
"enrollmentId": enroll.Id(),
|
|
"status": "enrolled",
|
|
})
|
|
}
|
|
|
|
// consumeBiometricChallenge validates and atomically marks a server-issued
|
|
// WebAuthn challenge as used. Returns (chalBytes, true, nil) iff the challenge
|
|
// exists, belongs to the user, is pending, and transitions to consumed.
|
|
// Prevents replay of the same challenge (R2-1).
|
|
//
|
|
// R3-9: uses READ COMMITTED + GetForUpdate, matching consumeSessionForSign
|
|
// and tryApproveOperation. For row-local CAS the FOR UPDATE row lock
|
|
// provides the atomicity we need; SERIALIZABLE would abort concurrent
|
|
// waiters with 40001 rather than let them block, costing us a retry.
|
|
// One pattern, one way.
|
|
func (s *Server) consumeBiometricChallenge(ctx context.Context, orgID, userID, credentialID string) ([]byte, bool, error) {
|
|
var out []byte
|
|
err := s.db.ORM.RunInTransactionWith(ctx, &orm.TxOptions{
|
|
Isolation: orm.IsolationReadCommitted,
|
|
MaxAttempts: 3,
|
|
}, func(tx orm.DB) error {
|
|
cred, gerr := orm.GetForUpdate[db.WebAuthnCredential](tx, credentialID)
|
|
if gerr != nil {
|
|
return gerr
|
|
}
|
|
if cred.OrgID != orgID || cred.UserID != userID {
|
|
return errors.New("challenge mismatch")
|
|
}
|
|
if cred.Status != "pending_registration" {
|
|
return errors.New("challenge already consumed")
|
|
}
|
|
if cred.Challenge == "" {
|
|
return errors.New("challenge missing")
|
|
}
|
|
// cred.Challenge is base64url(randomBytes) — decode back to raw bytes
|
|
// for strict equality against clientDataJSON.challenge (webauthn.Verify
|
|
// handles encoding variants).
|
|
raw, decErr := base64.URLEncoding.DecodeString(cred.Challenge)
|
|
if decErr != nil {
|
|
if raw2, err2 := base64.RawURLEncoding.DecodeString(cred.Challenge); err2 == nil {
|
|
raw = raw2
|
|
} else {
|
|
return errors.New("invalid stored challenge")
|
|
}
|
|
}
|
|
cred.Status = "consumed"
|
|
if uerr := cred.Update(); uerr != nil {
|
|
return uerr
|
|
}
|
|
out = raw
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return out, true, nil
|
|
}
|
|
|
|
func (s *Server) handleBiometricStatus(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
userID := getUserID(r.Context())
|
|
|
|
enrolls, err := orm.TypedQuery[db.DeviceEnrollment](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Filter("userId=", userID).
|
|
Filter("deviceType=", "biometric").
|
|
Order("-createdAt").
|
|
Limit(1).
|
|
GetAll(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
if len(enrolls) == 0 {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"userId": userID,
|
|
"status": "not_enrolled",
|
|
})
|
|
return
|
|
}
|
|
e := enrolls[0]
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"userId": userID,
|
|
"enrollmentId": e.Id(),
|
|
"status": e.Status,
|
|
"enrolledAt": e.CreatedAt,
|
|
})
|
|
}
|
|
|
|
// --- /v1/mpc/webauthn/{challenge,verify} — spec-named aliases ---
|
|
//
|
|
// The spec uses `/v1/mpc/webauthn/challenge` + `/v1/mpc/webauthn/verify`.
|
|
// We already expose `handleRegisterWebAuthnBegin` and `handleVerifyWebAuthn`;
|
|
// just route the spec paths to those handlers.
|
|
|
|
// decodeMessage decodes the spec-declared `encoding` (hex|base64) into raw
|
|
// bytes. STRICT — returns an error if the encoding is declared but the input
|
|
// does not decode. This eliminates the signing-oracle-for-arbitrary-strings
|
|
// class of attacks (F6). The empty-encoding default is "hex".
|
|
func decodeMessage(msg, encoding string) ([]byte, error) {
|
|
switch encoding {
|
|
case "", "hex":
|
|
b, err := hexDecodeSafe(msg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
case "base64":
|
|
b, err := base64DecodeSafe(msg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
default:
|
|
return nil, errUnknownEncoding
|
|
}
|
|
}
|