Files
mpc/pkg/api/handlers_wallets.go
T
zeekay 4e915d77c3 security: fix 15 custody audit findings, add regression + fuzz tests
Fixes all findings from the Blue Team defensive security review of the
redacted → redacted custody architecture transition.

HIGH:
- S-1: Implement P-256 ECDH + AES-256-GCM encryption of passkey backup shards
- W-2: Reject unparseable WebAuthn public keys (was silently skipping verification)
- O-1: Add Ed25519 binary signature verification package (pkg/integrity)

MEDIUM:
- W-1: Validate attestation is a real P-256 curve point, not raw attestation object
- W-3: Add origin validation to all WebAuthn flows (register, verify, trade approve)
- N-2: Remove plaintext TCP fallback from MPC node transport (TLS mandatory)
- K-1: Fail-closed on empty/whitespace orgID (prevents cross-tenant key leakage)
- A-1: Make AuditEntry immutable (Update/Delete return error)
- A-2: Add MPC-level signing audit log (wallet, message hash, intent, trigger)
- O-3: Invalidate backup shards after key reshare (stale shares are useless)
- O-2: Reject env/file password providers in production (require cloud HSM)

LOW:
- T-1: Add error handling + MarkSignFailed to signing goroutine
- T-2: Verify caller authorization in handleTradeSubmit
- K-2: Secret erasure stub now calls runtime.GC() instead of no-op
- N-1: Add VerifyConnection callback to reject peers without TLS certs

Tests: 69 unit + 7 fuzz targets, ~900K fuzz executions, 19/19 packages green.
Makefile: add -tags json1 for SQLite JSON function support in ORM tests.
2025-12-28 01:00:12 -08:00

227 lines
6.4 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleListWallets(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
vaultID := urlParam(r, "id")
q := orm.TypedQuery[db.Wallet](s.db.ORM).
Filter("vaultId=", vaultID).
Filter("orgId=", orgID).
Order("-createdAt")
// Per-user isolation: non-admin users only see their own wallets
role := getRole(r.Context())
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
}
if wallets == nil {
wallets = []*db.Wallet{}
}
writeJSON(w, http.StatusOK, wallets)
}
func (s *Server) handleCreateWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
vaultID := urlParam(r, "id")
var req struct {
Name string `json:"name"`
KeyType string `json:"key_type"`
Protocol string `json:"protocol"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.KeyType == "" {
req.KeyType = "secp256k1"
}
if req.Protocol == "" {
req.Protocol = "cggmp21"
}
if req.Protocol != "cggmp21" && req.Protocol != "frost" && req.Protocol != "lss" && req.Protocol != "sr25519" {
writeError(w, http.StatusBadRequest, "invalid protocol: must be cggmp21, frost, lss, or sr25519")
return
}
// Verify vault belongs to org
vault, err := orm.Get[db.Vault](s.db.ORM, vaultID)
if err != nil || vault.OrgID != orgID {
writeError(w, http.StatusNotFound, "vault not found")
return
}
// Trigger MPC keygen
result, err := s.mpc.TriggerKeygen(orgID, "")
if err != nil {
writeError(w, http.StatusInternalServerError, "keygen failed: "+err.Error())
return
}
// Get cluster status for participants
status := s.mpc.GetClusterStatus()
participants := []string{}
if status != nil {
total := status.ExpectedPeers + 1
for i := 0; i < total; i++ {
participants = append(participants, fmt.Sprintf("node%d", i))
}
}
name := req.Name
wal := orm.New[db.Wallet](s.db.ORM)
wal.VaultID = vaultID
wal.OrgID = orgID
wal.WalletID = result.WalletID
wal.Name = nilIfEmpty(name)
wal.KeyType = req.KeyType
wal.Protocol = req.Protocol
wal.ECDSAPubkey = nilIfEmpty(result.ECDSAPubKey)
wal.EDDSAPubkey = nilIfEmpty(result.EDDSAPubKey)
wal.EthAddress = nilIfEmpty(result.EthAddress)
wal.BtcAddress = nilIfEmpty(result.BtcAddress)
wal.SolAddress = nilIfEmpty(result.SolAddress)
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.fireWebhook(r.Context(), orgID, "keygen.complete", wal)
writeJSON(w, http.StatusCreated, wal)
}
func (s *Server) handleGetWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
writeJSON(w, http.StatusOK, wallet)
}
func (s *Server) handleGetWalletAddresses(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
addresses := map[string]interface{}{}
if wallet.EthAddress != nil {
addresses["ethereum"] = *wallet.EthAddress
addresses["lux"] = *wallet.EthAddress // EVM-compatible
addresses["xrp"] = *wallet.EthAddress // secp256k1-derived (simplified)
}
if wallet.BtcAddress != nil {
addresses["bitcoin"] = *wallet.BtcAddress
}
if wallet.SolAddress != nil {
addresses["solana"] = *wallet.SolAddress
addresses["ton"] = *wallet.SolAddress // Ed25519-based
}
writeJSON(w, http.StatusOK, addresses)
}
func (s *Server) handleReshareWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
var req struct {
NewThreshold int `json:"new_threshold"`
NewParticipants []string `json:"new_participants"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
if err := s.mpc.TriggerReshare(orgID, wallet.WalletID, req.NewThreshold, req.NewParticipants); err != nil {
writeError(w, http.StatusInternalServerError, "reshare failed: "+err.Error())
return
}
wallet.Version++
wallet.Threshold = req.NewThreshold
wallet.Participants = req.NewParticipants
if err := wallet.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update wallet")
return
}
// After reshare, invalidate all existing backup shards. The old key share
// material is now stale — backups encrypted with the old shard are useless.
// The user must create a new backup with the reshared key material.
activeBackups, _ := orm.TypedQuery[db.WalletBackup](s.db.ORM).
Filter("orgId=", orgID).
Filter("walletId=", walletID).
Filter("status=", "active").
GetAll(r.Context())
for _, backup := range activeBackups {
backup.Status = "invalidated_by_reshare"
backup.Update()
}
s.writeSigningAuditLog(r.Context(), orgID, wallet.WalletID, "", "", "reshare")
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "reshare_complete",
"backups_invalidated": len(activeBackups),
"action_required": "create new wallet backup with updated key share",
})
}
func (s *Server) handleWalletHistory(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
txs, err := orm.TypedQuery[db.Transaction](s.db.ORM).
Filter("walletId=", walletID).
Filter("orgId=", orgID).
Order("-createdAt").
Limit(100).
GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
if txs == nil {
txs = []*db.Transaction{}
}
writeJSON(w, http.StatusOK, txs)
}