mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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.
227 lines
6.4 KiB
Go
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)
|
|
}
|