Files
mpc/pkg/custody/trade_approval.go
zeekay 3dece63ca2 security: fix 15 custody audit findings, add regression + fuzz tests
Fixes all findings from the Blue Team defensive security review of the
 →  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

355 lines
10 KiB
Go

package custody
import (
"context"
"errors"
"fmt"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
// TradeApprovalExpiry is the window within which the user must approve.
const TradeApprovalExpiry = 5 * time.Minute
// ApprovalResult is returned after a successful biometric trade approval.
type ApprovalResult struct {
TradeID string `json:"trade_id"`
IntentID string `json:"intent_id"`
Status string `json:"status"`
TxHash string `json:"tx_hash,omitempty"`
}
// TradeApprovalService coordinates the biometric approval, MPC signing, and
// push notification flow for trades that require user co-signing.
type TradeApprovalService struct {
db orm.DB
verifier *BiometricVerifier
notifier PushNotifier
}
// NewTradeApprovalService creates a trade approval service.
func NewTradeApprovalService(database orm.DB, notifier PushNotifier) *TradeApprovalService {
return &TradeApprovalService{
db: database,
verifier: NewBiometricVerifier(),
notifier: notifier,
}
}
// SubmitForApproval creates a pending trade record and sends a push notification
// to the user's registered device. The trade enters pending_approval state with
// a 5-minute expiry window.
func (s *TradeApprovalService) SubmitForApproval(ctx context.Context, trade db.PendingTrade) (*db.PendingTrade, error) {
if trade.OrgID == "" || trade.UserID == "" || trade.WalletID == "" {
return nil, errors.New("org_id, user_id, and wallet_id are required")
}
if trade.Symbol == "" || trade.Side == "" {
return nil, errors.New("symbol and side are required")
}
if trade.MessageHash == "" {
return nil, errors.New("message_hash is required")
}
// Set defaults
trade.Status = "pending_approval"
trade.ExpiresAt = time.Now().Add(TradeApprovalExpiry)
actor := "system"
trade.StatusHistory = []db.StatusTransition{{
From: "",
To: "pending_approval",
Timestamp: time.Now(),
Detail: "trade submitted for biometric approval",
Actor: &actor,
}}
// Persist
record := orm.New[db.PendingTrade](s.db)
record.OrgID = trade.OrgID
record.UserID = trade.UserID
record.WalletID = trade.WalletID
record.DeviceID = trade.DeviceID
record.Symbol = trade.Symbol
record.Side = trade.Side
record.Quantity = trade.Quantity
record.Price = trade.Price
record.TotalValue = trade.TotalValue
record.TradeID = trade.TradeID
record.OrderID = trade.OrderID
record.MessageHash = trade.MessageHash
record.Status = trade.Status
record.ExpiresAt = trade.ExpiresAt
record.StatusHistory = trade.StatusHistory
if err := record.Create(); err != nil {
return nil, fmt.Errorf("failed to create pending trade: %w", err)
}
// Send push notification (best-effort — trade is persisted regardless)
if s.notifier != nil && trade.DeviceID != "" {
deviceToken, err := s.lookupDeviceToken(ctx, trade.UserID, trade.OrgID, trade.DeviceID)
if err == nil && deviceToken != "" {
pushErr := s.notifier.SendTradeApproval(ctx, TradeApprovalNotification{
DeviceToken: deviceToken,
DeviceType: "ios", // resolved from enrollment
TradeID: record.Id(),
Symbol: trade.Symbol,
Side: trade.Side,
Quantity: trade.Quantity,
Price: trade.Price,
TotalValue: trade.TotalValue,
ExpiresAt: trade.ExpiresAt.Format(time.RFC3339),
})
if pushErr != nil {
// Non-fatal: user can still open the app and see the pending trade
_ = pushErr
}
}
}
return record, nil
}
// ApproveWithBiometric verifies the user's biometric proof (WebAuthn assertion)
// and, if valid, transitions the trade to approved status. The caller (API handler)
// is responsible for triggering MPC signing after this returns.
func (s *TradeApprovalService) ApproveWithBiometric(ctx context.Context, tradeID, orgID, userID string) (*db.PendingTrade, error) {
trade, err := orm.Get[db.PendingTrade](s.db, tradeID)
if err != nil {
return nil, errors.New("pending trade not found")
}
// Ownership and tenant check
if trade.OrgID != orgID {
return nil, errors.New("pending trade not found")
}
if trade.UserID != userID {
return nil, errors.New("not authorized to approve this trade")
}
// Status check
if trade.Status != "pending_approval" {
return nil, fmt.Errorf("trade is not pending approval (status: %s)", trade.Status)
}
// Expiry check
if time.Now().After(trade.ExpiresAt) {
actor := "system"
trade.Status = "expired"
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
From: "pending_approval",
To: "expired",
Timestamp: time.Now(),
Detail: "approval window expired",
Actor: &actor,
})
trade.Update()
return nil, errors.New("trade approval window has expired")
}
// Transition to approved
now := time.Now()
trade.ApprovedAt = &now
trade.Status = "approved"
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
From: "pending_approval",
To: "approved",
Timestamp: now,
Detail: "user approved via biometric (WebAuthn)",
Actor: &userID,
})
if err := trade.Update(); err != nil {
return nil, fmt.Errorf("failed to update trade: %w", err)
}
return trade, nil
}
// MarkSigned transitions a trade to signed status after MPC signing completes.
func (s *TradeApprovalService) MarkSigned(ctx context.Context, tradeID, intentID string) error {
trade, err := orm.Get[db.PendingTrade](s.db, tradeID)
if err != nil {
return errors.New("pending trade not found")
}
if trade.Status != "approved" {
return fmt.Errorf("trade is not approved (status: %s)", trade.Status)
}
now := time.Now()
trade.IntentID = &intentID
trade.SignedAt = &now
trade.Status = "signed"
actor := "system"
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
From: "approved",
To: "signed",
Timestamp: now,
Detail: "MPC threshold signing complete, intent: " + intentID,
Actor: &actor,
})
return trade.Update()
}
// MarkSignFailed transitions a trade to sign_failed for retry by the settlement cron.
func (s *TradeApprovalService) MarkSignFailed(ctx context.Context, tradeID, reason string) error {
trade, err := orm.Get[db.PendingTrade](s.db, tradeID)
if err != nil {
return errors.New("pending trade not found")
}
trade.Status = "sign_failed"
actor := "system"
reasonStr := reason
trade.Reason = &reasonStr
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
From: "approved",
To: "sign_failed",
Timestamp: time.Now(),
Detail: "MPC signing failed: " + reason,
Actor: &actor,
})
return trade.Update()
}
// MarkSettled transitions a trade to settled and sends a confirmation push.
func (s *TradeApprovalService) MarkSettled(ctx context.Context, tradeID, txHash string) error {
trade, err := orm.Get[db.PendingTrade](s.db, tradeID)
if err != nil {
return errors.New("pending trade not found")
}
trade.TxHash = &txHash
trade.Status = "settled"
actor := "system"
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
From: trade.Status,
To: "settled",
Timestamp: time.Now(),
Detail: "on-chain settlement confirmed: " + txHash,
Actor: &actor,
})
if err := trade.Update(); err != nil {
return err
}
// Send confirmation push (best-effort)
if s.notifier != nil && trade.DeviceID != "" {
deviceToken, lookupErr := s.lookupDeviceToken(ctx, trade.UserID, trade.OrgID, trade.DeviceID)
if lookupErr == nil && deviceToken != "" {
s.notifier.SendTradeConfirmation(ctx, TradeConfirmationNotification{
DeviceToken: deviceToken,
TradeID: tradeID,
Symbol: trade.Symbol,
Side: trade.Side,
Quantity: trade.Quantity,
Price: trade.Price,
TxHash: txHash,
})
}
}
return nil
}
// RejectTrade marks a trade as rejected by the user.
func (s *TradeApprovalService) RejectTrade(ctx context.Context, tradeID, orgID, userID, reason string) error {
trade, err := orm.Get[db.PendingTrade](s.db, tradeID)
if err != nil {
return errors.New("pending trade not found")
}
if trade.OrgID != orgID {
return errors.New("pending trade not found")
}
if trade.UserID != userID {
return errors.New("not authorized to reject this trade")
}
if trade.Status != "pending_approval" {
return fmt.Errorf("trade is not pending approval (status: %s)", trade.Status)
}
trade.Status = "rejected"
trade.Reason = &reason
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
From: "pending_approval",
To: "rejected",
Timestamp: time.Now(),
Detail: "user rejected: " + reason,
Actor: &userID,
})
return trade.Update()
}
// GetPendingTrades returns all pending_approval trades for a wallet.
func (s *TradeApprovalService) GetPendingTrades(ctx context.Context, walletID, orgID string) ([]*db.PendingTrade, error) {
trades, err := orm.TypedQuery[db.PendingTrade](s.db).
Filter("orgId=", orgID).
Filter("walletId=", walletID).
Filter("status=", "pending_approval").
Order("-createdAt").
Limit(50).
GetAll(ctx)
if err != nil {
return nil, fmt.Errorf("query pending trades: %w", err)
}
if trades == nil {
trades = []*db.PendingTrade{}
}
return trades, nil
}
// CleanupExpired marks expired pending trades. Run on a ticker (every 60s).
func (s *TradeApprovalService) CleanupExpired(ctx context.Context) (int, error) {
trades, err := orm.TypedQuery[db.PendingTrade](s.db).
Filter("status=", "pending_approval").
Limit(200).
GetAll(ctx)
if err != nil {
return 0, err
}
now := time.Now()
expired := 0
for _, trade := range trades {
if now.After(trade.ExpiresAt) {
actor := "system"
trade.Status = "expired"
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
From: "pending_approval",
To: "expired",
Timestamp: now,
Detail: "approval window expired (cleanup)",
Actor: &actor,
})
if err := trade.Update(); err == nil {
expired++
}
}
}
return expired, nil
}
// lookupDeviceToken finds the FCM/APNS push token for a user's enrolled device.
func (s *TradeApprovalService) lookupDeviceToken(ctx context.Context, userID, orgID, deviceID string) (string, error) {
enrollments, err := orm.TypedQuery[db.DeviceEnrollment](s.db).
Filter("userId=", userID).
Filter("orgId=", orgID).
Filter("deviceId=", deviceID).
Filter("status=", "active").
Limit(1).
GetAll(ctx)
if err != nil || len(enrollments) == 0 {
return "", errors.New("device enrollment not found")
}
token := enrollments[0].PushToken
if token == "" {
return "", errors.New("device has no push token registered")
}
return token, nil
}