mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
528 lines
16 KiB
Go
528 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/ecdh"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/hanzoai/orm"
|
|
|
|
"github.com/luxfi/mpc/pkg/db"
|
|
)
|
|
|
|
// --- Intents ---
|
|
|
|
func (s *Server) handleCreateIntent(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
|
|
var req struct {
|
|
WalletID string `json:"wallet_id"`
|
|
IntentType string `json:"intent_type"` // buy, sell, transfer, bridge
|
|
Chain string `json:"chain"`
|
|
ToAddress string `json:"to_address"`
|
|
Amount string `json:"amount"`
|
|
Token string `json:"token,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.WalletID == "" || req.Chain == "" || req.IntentType == "" || req.Amount == "" {
|
|
writeError(w, http.StatusBadRequest, "wallet_id, intent_type, chain, and amount are required")
|
|
return
|
|
}
|
|
|
|
// Verify wallet belongs to org
|
|
wallet, err := orm.Get[db.Wallet](s.db.ORM, req.WalletID)
|
|
if err != nil || wallet.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "wallet not found")
|
|
return
|
|
}
|
|
|
|
intent := orm.New[db.Intent](s.db.ORM)
|
|
intent.OrgID = orgID
|
|
intent.WalletID = req.WalletID
|
|
intent.IntentType = req.IntentType
|
|
intent.Chain = req.Chain
|
|
intent.ToAddress = nilIfEmpty(req.ToAddress)
|
|
intent.Amount = req.Amount
|
|
intent.Token = nilIfEmpty(req.Token)
|
|
intent.Status = "pending_sign"
|
|
|
|
// Compute intent hash from canonical fields
|
|
intent.IntentHash = computeIntentHash(orgID, req.WalletID, req.IntentType, req.Chain, req.ToAddress, req.Amount, deref(nilIfEmpty(req.Token)))
|
|
|
|
if err := intent.Create(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create intent: "+err.Error())
|
|
return
|
|
}
|
|
|
|
s.fireWebhook(r.Context(), orgID, "intent.created", intent)
|
|
writeJSON(w, http.StatusCreated, intent)
|
|
}
|
|
|
|
func (s *Server) handleListIntents(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
|
|
q := orm.TypedQuery[db.Intent](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Order("-createdAt").
|
|
Limit(100)
|
|
|
|
if status := r.URL.Query().Get("status"); status != "" {
|
|
q = q.Filter("status=", status)
|
|
}
|
|
|
|
intents, err := q.GetAll(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
if intents == nil {
|
|
intents = []*db.Intent{}
|
|
}
|
|
writeJSON(w, http.StatusOK, intents)
|
|
}
|
|
|
|
func (s *Server) handleGetIntent(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
intentID := urlParam(r, "id")
|
|
|
|
intent, err := orm.Get[db.Intent](s.db.ORM, intentID)
|
|
if err != nil || intent.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "intent not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, intent)
|
|
}
|
|
|
|
func (s *Server) handleSignIntent(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
intentID := urlParam(r, "id")
|
|
|
|
var req struct {
|
|
Signature string `json:"signature"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Signature == "" {
|
|
writeError(w, http.StatusBadRequest, "signature is required")
|
|
return
|
|
}
|
|
|
|
intent, err := orm.Get[db.Intent](s.db.ORM, intentID)
|
|
if err != nil || intent.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "intent not found")
|
|
return
|
|
}
|
|
|
|
if intent.Status != "pending_sign" {
|
|
writeError(w, http.StatusConflict, "intent is not in pending_sign status")
|
|
return
|
|
}
|
|
|
|
sig := req.Signature
|
|
intent.Signature = &sig
|
|
intent.Status = "signed"
|
|
sys := "user"
|
|
intent.StatusHistory = append(intent.StatusHistory, db.StatusTransition{
|
|
From: "pending_sign", To: "signed", Detail: "user signed via MPC wallet", Actor: &sys,
|
|
})
|
|
|
|
if err := intent.Update(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update intent")
|
|
return
|
|
}
|
|
|
|
s.fireWebhook(r.Context(), orgID, "intent.signed", intent)
|
|
writeJSON(w, http.StatusOK, intent)
|
|
}
|
|
|
|
func (s *Server) handleCoSignIntent(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
intentID := urlParam(r, "id")
|
|
|
|
var req struct {
|
|
KeyID string `json:"key_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.KeyID == "" {
|
|
writeError(w, http.StatusBadRequest, "key_id is required")
|
|
return
|
|
}
|
|
|
|
intent, err := orm.Get[db.Intent](s.db.ORM, intentID)
|
|
if err != nil || intent.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "intent not found")
|
|
return
|
|
}
|
|
|
|
if intent.Status != "signed" {
|
|
writeError(w, http.StatusConflict, "intent must be signed before co-signing")
|
|
return
|
|
}
|
|
|
|
// Server-side HSM co-signing: the server calls the HSM directly.
|
|
// This prevents a compromised client from submitting forged co-signatures.
|
|
if s.hsm == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "HSM provider not configured")
|
|
return
|
|
}
|
|
|
|
// Sign the intent hash with the HSM
|
|
hashBytes, hexErr := hex.DecodeString(intent.IntentHash)
|
|
if hexErr != nil {
|
|
writeError(w, http.StatusInternalServerError, "invalid intent hash")
|
|
return
|
|
}
|
|
|
|
sig, signErr := s.hsm.Sign(r.Context(), req.KeyID, hashBytes)
|
|
if signErr != nil {
|
|
writeError(w, http.StatusInternalServerError, "HSM co-signing failed: "+signErr.Error())
|
|
return
|
|
}
|
|
|
|
// Verify the signature
|
|
ok, verifyErr := s.hsm.Verify(r.Context(), req.KeyID, hashBytes, sig)
|
|
if verifyErr != nil || !ok {
|
|
writeError(w, http.StatusInternalServerError, "HSM signature verification failed")
|
|
return
|
|
}
|
|
|
|
cosig := hex.EncodeToString(sig)
|
|
intent.CoSignature = &cosig
|
|
keyID := req.KeyID
|
|
intent.CoSignerKeyID = &keyID
|
|
intent.Status = "co_signed"
|
|
actor := "hsm"
|
|
intent.StatusHistory = append(intent.StatusHistory, db.StatusTransition{
|
|
From: "signed", To: "co_signed",
|
|
Detail: "server-side HSM co-signed with key " + req.KeyID,
|
|
Actor: &actor,
|
|
})
|
|
|
|
if err := intent.Update(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update intent")
|
|
return
|
|
}
|
|
|
|
s.fireWebhook(r.Context(), orgID, "intent.co_signed", intent)
|
|
writeJSON(w, http.StatusOK, intent)
|
|
}
|
|
|
|
// --- Settlements ---
|
|
|
|
func (s *Server) handleListSettlements(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
|
|
q := orm.TypedQuery[db.Settlement](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Order("-createdAt").
|
|
Limit(100)
|
|
|
|
if status := r.URL.Query().Get("status"); status != "" {
|
|
q = q.Filter("status=", status)
|
|
}
|
|
|
|
settlements, err := q.GetAll(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
if settlements == nil {
|
|
settlements = []*db.Settlement{}
|
|
}
|
|
writeJSON(w, http.StatusOK, settlements)
|
|
}
|
|
|
|
func (s *Server) handleGetSettlement(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
settlementID := urlParam(r, "id")
|
|
|
|
settlement, err := orm.Get[db.Settlement](s.db.ORM, settlementID)
|
|
if err != nil || settlement.OrgID != orgID {
|
|
writeError(w, http.StatusNotFound, "settlement not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, settlement)
|
|
}
|
|
|
|
// --- Wallet Backup ---
|
|
|
|
func (s *Server) handleCreateWalletBackup(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
|
|
}
|
|
|
|
var req struct {
|
|
Threshold int `json:"threshold"`
|
|
TotalShards int `json:"total_shards"`
|
|
Destinations []string `json:"destinations"`
|
|
UserWebAuthnPubKey string `json:"user_webauthn_pub_key,omitempty"` // P-256 public key for passkey-encrypted backup
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Rate limiting: max 1 backup per wallet per hour.
|
|
// Count existing active backups for this wallet; if the most recent was
|
|
// created recently, reject to prevent backup spam / key share exfiltration.
|
|
existingBackups, _ := orm.TypedQuery[db.WalletBackup](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Filter("walletId=", walletID).
|
|
Filter("status=", "active").
|
|
Order("-createdAt").
|
|
Limit(1).
|
|
GetAll(r.Context())
|
|
if len(existingBackups) > 0 {
|
|
// The ORM stores createdAt; use the struct's UpdatedAt or count as proxy.
|
|
// Since we can't easily access created_at from orm.Model, enforce a simple
|
|
// limit: max 3 active backups per wallet.
|
|
allBackups, _ := orm.TypedQuery[db.WalletBackup](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Filter("walletId=", walletID).
|
|
Filter("status=", "active").
|
|
GetAll(r.Context())
|
|
if len(allBackups) >= 3 {
|
|
writeError(w, http.StatusTooManyRequests,
|
|
"maximum of 3 active backups per wallet; revoke an existing backup first")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Default: 2-of-3 iCloud + HSM + offline (fault tolerant)
|
|
if req.Threshold == 0 {
|
|
req.Threshold = 2
|
|
req.TotalShards = 3
|
|
req.Destinations = []string{"icloud", "hsm", "offline"}
|
|
}
|
|
|
|
// NON-CUSTODIAL: The backup shard stored in HSM is encrypted with the
|
|
// user's WebAuthn passkey public key. The company stores the ciphertext
|
|
// but CANNOT decrypt it — only the user's biometric (via their passkey's
|
|
// private key in Secure Enclave) can decrypt the backup shard.
|
|
//
|
|
// This ensures: ATS shard (2) + encrypted_backup (3) = company CANNOT sign.
|
|
// User must present their passkey (biometric) to decrypt shard 3.
|
|
var userPasskeyPubKey string
|
|
if req.UserWebAuthnPubKey != "" {
|
|
userPasskeyPubKey = req.UserWebAuthnPubKey
|
|
} else {
|
|
// Look up user's WebAuthn credential from DB
|
|
creds, _ := orm.TypedQuery[db.WebAuthnCredential](s.db.ORM).
|
|
Filter("userId=", getUserID(r.Context())).
|
|
Filter("orgId=", orgID).
|
|
Filter("status=", "active").
|
|
Order("-createdAt").
|
|
Limit(1).
|
|
GetAll(r.Context())
|
|
if len(creds) > 0 {
|
|
userPasskeyPubKey = creds[0].PublicKey
|
|
}
|
|
}
|
|
|
|
// Perform P-256 ECDH + AES-256-GCM encryption of the backup shard.
|
|
// The user's WebAuthn public key (P-256) is used for key agreement. An ephemeral
|
|
// P-256 keypair is generated server-side; the shared secret derives the AES key.
|
|
// Only the user's passkey private key (in Secure Enclave) can reverse the ECDH.
|
|
if userPasskeyPubKey == "" {
|
|
writeError(w, http.StatusBadRequest, "user_webauthn_pub_key is required for non-custodial backup; register a WebAuthn credential first")
|
|
return
|
|
}
|
|
|
|
// Decode user's P-256 public key (base64-encoded uncompressed point)
|
|
userPubBytes, err := base64.StdEncoding.DecodeString(userPasskeyPubKey)
|
|
if err != nil || len(userPubBytes) < 65 || userPubBytes[0] != 0x04 {
|
|
writeError(w, http.StatusBadRequest, "invalid P-256 public key; expected base64-encoded uncompressed point (65 bytes, 0x04 prefix)")
|
|
return
|
|
}
|
|
userECDH, err := ecdh.P256().NewPublicKey(userPubBytes)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid P-256 public key: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Load the wallet's key share from the MPC backend (org-scoped)
|
|
keyShareData, err := s.mpc.ExportKeyShare(orgID, walletID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to export key share for backup")
|
|
return
|
|
}
|
|
|
|
// Ephemeral P-256 keypair for ECDH
|
|
ephPriv, err := ecdh.P256().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate ephemeral key")
|
|
return
|
|
}
|
|
ephPub := ephPriv.PublicKey()
|
|
|
|
// ECDH shared secret → SHA-256 → AES-256 key
|
|
sharedSecret, err := ephPriv.ECDH(userECDH)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ECDH key agreement failed")
|
|
return
|
|
}
|
|
aesKey := sha256.Sum256(sharedSecret)
|
|
|
|
// AES-256-GCM encrypt the key share
|
|
block, err := aes.NewCipher(aesKey[:])
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create cipher")
|
|
return
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create GCM")
|
|
return
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate nonce")
|
|
return
|
|
}
|
|
encrypted := gcm.Seal(nonce, nonce, keyShareData, nil) // nonce || ciphertext || tag
|
|
|
|
// Zero sensitive material
|
|
for i := range sharedSecret {
|
|
sharedSecret[i] = 0
|
|
}
|
|
for i := range aesKey {
|
|
aesKey[i] = 0
|
|
}
|
|
for i := range keyShareData {
|
|
keyShareData[i] = 0
|
|
}
|
|
|
|
backup := orm.New[db.WalletBackup](s.db.ORM)
|
|
backup.OrgID = orgID
|
|
backup.WalletID = walletID
|
|
backup.Threshold = req.Threshold
|
|
backup.TotalShards = req.TotalShards
|
|
backup.Status = "active"
|
|
backup.UserPasskeyFingerprint = userPasskeyPubKey
|
|
backup.EncryptedKeyShare = encrypted // AES-256-GCM ciphertext, decryptable ONLY with user's passkey
|
|
backup.EphemeralPublicKey = base64.StdEncoding.EncodeToString(ephPub.Bytes()) // needed for user-side ECDH
|
|
|
|
if err := backup.Create(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create backup record: "+err.Error())
|
|
return
|
|
}
|
|
|
|
s.fireWebhook(r.Context(), orgID, "wallet.backup_created", backup)
|
|
writeJSON(w, http.StatusCreated, backup)
|
|
}
|
|
|
|
func (s *Server) handleGetWalletBackup(w http.ResponseWriter, r *http.Request) {
|
|
orgID := getOrgID(r.Context())
|
|
walletID := urlParam(r, "id")
|
|
|
|
backups, err := orm.TypedQuery[db.WalletBackup](s.db.ORM).
|
|
Filter("orgId=", orgID).
|
|
Filter("walletId=", walletID).
|
|
Filter("status=", "active").
|
|
GetAll(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
return
|
|
}
|
|
if backups == nil {
|
|
backups = []*db.WalletBackup{}
|
|
}
|
|
writeJSON(w, http.StatusOK, backups)
|
|
}
|
|
|
|
// --- Intent Expiry Reaper ---
|
|
|
|
// StartIntentReaper launches a background goroutine that periodically
|
|
// marks expired intents. This prevents stuck intents from accumulating
|
|
// when the HSM is down or the co-sign step is delayed.
|
|
func (s *Server) StartIntentReaper(ctx context.Context, interval time.Duration) {
|
|
if interval == 0 {
|
|
interval = 5 * time.Minute
|
|
}
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.reapExpiredIntents(ctx)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *Server) reapExpiredIntents(ctx context.Context) {
|
|
// Query intents that are not in terminal states
|
|
activeStatuses := []string{"pending_sign", "signed", "co_signed", "recorded", "matched"}
|
|
for _, status := range activeStatuses {
|
|
intents, err := orm.TypedQuery[db.Intent](s.db.ORM).
|
|
Filter("status=", status).
|
|
Limit(100).
|
|
GetAll(ctx)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
now := time.Now()
|
|
for _, intent := range intents {
|
|
if intent.ExpiresAt != nil && now.After(*intent.ExpiresAt) {
|
|
actor := "system"
|
|
intent.Status = "expired"
|
|
intent.StatusHistory = append(intent.StatusHistory, db.StatusTransition{
|
|
From: status,
|
|
To: "expired",
|
|
Detail: fmt.Sprintf("expired after %s in %s state", now.Sub(*intent.ExpiresAt).Round(time.Second), status),
|
|
Actor: &actor,
|
|
})
|
|
intent.Update()
|
|
s.fireWebhook(ctx, intent.OrgID, "intent.expired", map[string]string{
|
|
"intent_id": intent.Id(),
|
|
"expired_from": status,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
// computeIntentHash produces a domain-separated, versioned SHA-256 hash of the
|
|
// intent's canonical fields. The "lux-mpc-intent:v1|" prefix prevents
|
|
// cross-version collision if the canonical format ever changes.
|
|
func computeIntentHash(orgID, walletID, intentType, chain, toAddr, amount, token string) string {
|
|
canonical := "lux-mpc-intent:v1|" +
|
|
"amount=" + amount + "|chain=" + chain + "|orgId=" + orgID +
|
|
"|to=" + toAddr + "|token=" + token + "|type=" + intentType +
|
|
"|walletId=" + walletID
|
|
h := sha256.Sum256([]byte(canonical))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func deref(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|