diff --git a/backend/api-gateway/nginx/install-mpc-grpc.sh b/backend/api-gateway/nginx/install-mpc-grpc.sh new file mode 100644 index 00000000..29bf74f3 --- /dev/null +++ b/backend/api-gateway/nginx/install-mpc-grpc.sh @@ -0,0 +1,276 @@ +#!/bin/bash +# ============================================================================= +# MPC gRPC 代理 - Nginx 配置安装脚本 +# ============================================================================= +# 用途: 为 Service Party App 提供 gRPC 连接到 Message Router +# 域名: mpc-grpc.szaiai.com +# +# 前提条件: +# 1. Nginx 已安装并运行 +# 2. Certbot 已安装 +# 3. DNS 已配置 mpc-grpc.szaiai.com 指向此服务器 +# 4. Message Router 在后端服务器 (192.168.1.111:50051) 运行 +# +# 此脚本完全独立,不影响现有服务 +# ============================================================================= + +set -e + +DOMAIN="mpc-grpc.szaiai.com" +EMAIL="admin@szaiai.com" +BACKEND_HOST="192.168.1.111" +BACKEND_PORT="50051" + +# 颜色 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# 检查 root 权限 +check_root() { + if [ "$EUID" -ne 0 ]; then + log_error "请使用 root 权限运行: sudo ./install-mpc-grpc.sh" + exit 1 + fi +} + +# 检查前提条件 +check_prerequisites() { + log_info "检查前提条件..." + + # 检查 Nginx + if ! command -v nginx &> /dev/null; then + log_error "Nginx 未安装,请先安装 Nginx" + exit 1 + fi + + # 检查 Certbot + if ! command -v certbot &> /dev/null; then + log_error "Certbot 未安装,请先安装 Certbot" + exit 1 + fi + + # 检查 Nginx 是否支持 http2 和 grpc + if ! nginx -V 2>&1 | grep -q "http_v2_module"; then + log_warn "Nginx 可能不支持 HTTP/2,gRPC 需要 HTTP/2 支持" + fi + + log_success "前提条件检查通过" +} + +# 步骤 1: 创建临时 HTTP 配置用于证书申请 +configure_http() { + log_info "步骤 1/4: 创建临时 HTTP 配置..." + + # 确保 certbot webroot 目录存在 + mkdir -p /var/www/certbot + + # 创建临时 HTTP 配置 + cat > /etc/nginx/sites-available/$DOMAIN << EOF +# 临时 HTTP 配置 - 用于 Let's Encrypt 验证 +server { + listen 80; + listen [::]:80; + server_name $DOMAIN; + + # Let's Encrypt 验证目录 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 200 'MPC gRPC proxy - waiting for SSL certificate'; + add_header Content-Type text/plain; + } +} +EOF + + # 启用站点 + ln -sf /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/ + + # 测试并重载 + nginx -t && systemctl reload nginx + log_success "临时 HTTP 配置完成" +} + +# 步骤 2: 申请 SSL 证书 +obtain_certificate() { + log_info "步骤 2/4: 申请 Let's Encrypt SSL 证书..." + + # 检查证书是否已存在 + if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then + log_warn "证书已存在,跳过申请" + return 0 + fi + + # 申请证书 + certbot certonly \ + --webroot \ + --webroot-path=/var/www/certbot \ + --email $EMAIL \ + --agree-tos \ + --no-eff-email \ + -d $DOMAIN + + log_success "SSL 证书申请成功" +} + +# 步骤 3: 配置 gRPC 代理 +configure_grpc() { + log_info "步骤 3/4: 配置 Nginx gRPC 代理..." + + # 获取脚本所在目录 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # 复制 gRPC 配置 + cp "$SCRIPT_DIR/mpc-grpc.szaiai.com.conf" /etc/nginx/sites-available/$DOMAIN + + # 测试并重载 + nginx -t && systemctl reload nginx + log_success "gRPC 代理配置完成" +} + +# 步骤 4: 验证配置 +verify_setup() { + log_info "步骤 4/4: 验证配置..." + + # 检查 Nginx 状态 + if systemctl is-active --quiet nginx; then + log_success "Nginx 运行正常" + else + log_error "Nginx 未运行" + exit 1 + fi + + # 检查证书 + if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then + log_success "SSL 证书已就绪" + else + log_error "SSL 证书未找到" + exit 1 + fi + + # 检查配置语法 + if nginx -t 2>/dev/null; then + log_success "Nginx 配置语法正确" + else + log_error "Nginx 配置语法错误" + exit 1 + fi + + log_success "验证完成" +} + +# 显示完成信息 +show_completion() { + echo "" + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN} MPC gRPC 代理安装完成!${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" + echo -e "gRPC 端点: ${BLUE}mpc-grpc.szaiai.com:443${NC}" + echo "" + echo "架构:" + echo " Service Party App → Nginx (SSL/gRPC) → Message Router" + echo " ↓" + echo " $DOMAIN:443" + echo " ↓" + echo " $BACKEND_HOST:$BACKEND_PORT" + echo "" + echo "Service Party App 连接配置:" + echo " gRPC 地址: mpc-grpc.szaiai.com:443" + echo " TLS: 启用" + echo "" + echo "常用命令:" + echo " 查看 Nginx 状态: systemctl status nginx" + echo " 重载 Nginx: systemctl reload nginx" + echo " 查看证书: certbot certificates" + echo " 查看日志: tail -f /var/log/nginx/$DOMAIN.access.log" + echo "" + echo -e "${YELLOW}注意: 确保后端 Message Router ($BACKEND_HOST:$BACKEND_PORT) 正在运行${NC}" + echo "" +} + +# 显示使用帮助 +show_help() { + echo "用法: $0 [选项]" + echo "" + echo "选项:" + echo " --help, -h 显示帮助信息" + echo " --verify 仅验证现有配置" + echo " --uninstall 卸载配置" + echo "" +} + +# 卸载配置 +uninstall() { + log_info "卸载 MPC gRPC 代理配置..." + + # 移除站点配置 + rm -f /etc/nginx/sites-enabled/$DOMAIN + rm -f /etc/nginx/sites-available/$DOMAIN + + # 重载 Nginx + nginx -t && systemctl reload nginx + + log_success "配置已卸载" + log_info "注意: SSL 证书未删除,如需删除请运行: certbot delete --cert-name $DOMAIN" +} + +# 主函数 +main() { + case "${1:-}" in + --help|-h) + show_help + exit 0 + ;; + --verify) + check_prerequisites + verify_setup + exit 0 + ;; + --uninstall) + check_root + uninstall + exit 0 + ;; + esac + + echo "" + echo "============================================" + echo " MPC gRPC 代理 - Nginx 安装脚本" + echo " 域名: $DOMAIN" + echo " 后端: $BACKEND_HOST:$BACKEND_PORT" + echo "============================================" + echo "" + + check_root + check_prerequisites + + echo "" + log_warn "请确保以下条件已满足:" + echo " 1. 域名 $DOMAIN 的 DNS A 记录已指向本服务器 IP" + echo " 2. Message Router 已在 $BACKEND_HOST:$BACKEND_PORT 运行" + echo "" + read -p "是否继续安装? (y/n): " confirm + + if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then + log_info "安装已取消" + exit 0 + fi + + configure_http + obtain_certificate + configure_grpc + verify_setup + show_completion +} + +main "$@" diff --git a/backend/api-gateway/nginx/mpc-grpc.szaiai.com.conf b/backend/api-gateway/nginx/mpc-grpc.szaiai.com.conf new file mode 100644 index 00000000..52e321f5 --- /dev/null +++ b/backend/api-gateway/nginx/mpc-grpc.szaiai.com.conf @@ -0,0 +1,95 @@ +# ============================================================================= +# MPC Message Router gRPC 代理配置 +# ============================================================================= +# 域名: mpc-grpc.szaiai.com +# 用途: 为 Service Party App 提供 gRPC 连接到 Message Router +# 后端: Message Router gRPC 服务 (端口 50051) +# +# 部署步骤: +# 1. 放置到: /etc/nginx/sites-available/mpc-grpc.szaiai.com +# 2. 启用: ln -s /etc/nginx/sites-available/mpc-grpc.szaiai.com /etc/nginx/sites-enabled/ +# 3. 申请证书: certbot certonly --nginx -d mpc-grpc.szaiai.com +# 4. 重载: nginx -t && systemctl reload nginx +# +# 注意: 此配置完全独立,不影响现有服务 +# ============================================================================= + +# HTTP 重定向到 HTTPS (gRPC 必须使用 HTTPS) +server { + listen 80; + listen [::]:80; + server_name mpc-grpc.szaiai.com; + + # Let's Encrypt 验证目录 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 重定向到 HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS + gRPC 配置 +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name mpc-grpc.szaiai.com; + + # SSL 证书 (Let's Encrypt) + # 首次部署前需要先申请证书: + # certbot certonly --nginx -d mpc-grpc.szaiai.com + ssl_certificate /etc/letsencrypt/live/mpc-grpc.szaiai.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/mpc-grpc.szaiai.com/privkey.pem; + + # SSL 配置优化 + ssl_session_timeout 1d; + ssl_session_cache shared:MPC_SSL:10m; + ssl_session_tickets off; + + # 现代加密套件 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # HSTS + add_header Strict-Transport-Security "max-age=63072000" always; + + # 日志 + access_log /var/log/nginx/mpc-grpc.szaiai.com.access.log; + error_log /var/log/nginx/mpc-grpc.szaiai.com.error.log; + + # gRPC 代理到 Message Router + # 后端服务器: 192.168.1.111 (与其他服务相同) + # Message Router gRPC 端口: 50051 + location / { + # gRPC 代理 + grpc_pass grpc://192.168.1.111:50051; + + # gRPC 超时设置 + # 会话等待时间较长 (24小时倒计时),需要较长超时 + grpc_read_timeout 300s; + grpc_send_timeout 300s; + grpc_connect_timeout 60s; + + # 错误处理 + error_page 502 = /error502grpc; + } + + # gRPC 错误处理 + location = /error502grpc { + internal; + default_type application/grpc; + add_header grpc-status 14; + add_header grpc-message "Message Router unavailable"; + return 204; + } + + # HTTP 健康检查端点 (用于监控) + location = /health { + access_log off; + return 200 '{"status":"ok","service":"mpc-grpc-proxy"}'; + add_header Content-Type application/json; + } +} diff --git a/backend/mpc-system/services/account/adapters/input/http/co_managed_handler.go b/backend/mpc-system/services/account/adapters/input/http/co_managed_handler.go new file mode 100644 index 00000000..e5afb00d --- /dev/null +++ b/backend/mpc-system/services/account/adapters/input/http/co_managed_handler.go @@ -0,0 +1,302 @@ +package http + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + grpcclient "github.com/rwadurian/mpc-system/services/account/adapters/output/grpc" + "github.com/rwadurian/mpc-system/pkg/logger" + "go.uber.org/zap" +) + +// CoManagedHTTPHandler handles HTTP requests for co-managed wallets +// This is a completely independent handler that does not affect existing functionality +type CoManagedHTTPHandler struct { + sessionCoordinatorClient *grpcclient.SessionCoordinatorClient +} + +// NewCoManagedHTTPHandler creates a new CoManagedHTTPHandler +func NewCoManagedHTTPHandler( + sessionCoordinatorClient *grpcclient.SessionCoordinatorClient, +) *CoManagedHTTPHandler { + return &CoManagedHTTPHandler{ + sessionCoordinatorClient: sessionCoordinatorClient, + } +} + +// RegisterRoutes registers HTTP routes for co-managed wallets +func (h *CoManagedHTTPHandler) RegisterRoutes(router *gin.RouterGroup) { + coManaged := router.Group("/co-managed") + { + coManaged.POST("/sessions", h.CreateSession) + coManaged.POST("/sessions/:sessionId/join", h.JoinSession) + coManaged.GET("/sessions/:sessionId", h.GetSessionStatus) + } +} + +// generateInviteCode generates a random invite code in format XXXX-XXXX-XXXX +func generateInviteCode() string { + bytes := make([]byte, 6) + rand.Read(bytes) + code := fmt.Sprintf("%X", bytes) + return fmt.Sprintf("%s-%s-%s", code[0:4], code[4:8], code[8:12]) +} + +// ============================================ +// Create Co-Managed Wallet Session +// ============================================ + +// CreateCoManagedSessionRequest represents the request for creating a co-managed wallet session +type CreateCoManagedSessionRequest struct { + WalletName string `json:"wallet_name" binding:"required"` // Wallet name + ThresholdT int `json:"threshold_t" binding:"required,min=1"` // Signing threshold (actual signers needed = t+1) + ThresholdN int `json:"threshold_n" binding:"required,min=2"` // Total parties + InitiatorPartyID string `json:"initiator_party_id" binding:"required"` // Initiator's party ID + InitiatorName string `json:"initiator_name"` // Initiator's display name + PersistentCount int `json:"persistent_count"` // Number of server parties (default 2) +} + +// CreateSession handles creating a new co-managed wallet session +func (h *CoManagedHTTPHandler) CreateSession(c *gin.Context) { + var req CreateCoManagedSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate threshold + if req.ThresholdT >= req.ThresholdN { + c.JSON(http.StatusBadRequest, gin.H{"error": "threshold_t must be less than threshold_n"}) + return + } + + // Default persistent count is 2 (platform backup parties) + persistentCount := req.PersistentCount + if persistentCount <= 0 { + persistentCount = 2 + } + + // Calculate external party count + externalCount := req.ThresholdN - persistentCount + if externalCount < 1 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "threshold_n must be greater than persistent_count to allow external participants", + }) + return + } + + // Generate invite code + inviteCode := generateInviteCode() + + // Call session coordinator via gRPC + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + logger.Info("Creating co-managed keygen session", + zap.String("wallet_name", req.WalletName), + zap.Int("threshold_n", req.ThresholdN), + zap.Int("threshold_t", req.ThresholdT), + zap.Int("persistent_count", persistentCount), + zap.Int("external_count", externalCount), + zap.String("initiator_party_id", req.InitiatorPartyID)) + + resp, err := h.sessionCoordinatorClient.CreateCoManagedKeygenSession( + ctx, + req.WalletName, + inviteCode, + int32(req.ThresholdN), + int32(req.ThresholdT), + int32(persistentCount), + int32(externalCount), + 3600, // 1 hour expiry for session creation phase + ) + + if err != nil { + logger.Error("Failed to create co-managed keygen session", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Get wildcard join token for external participants + wildcardToken := "" + if token, ok := resp.JoinTokens["*"]; ok { + wildcardToken = token + } + + logger.Info("Co-managed keygen session created successfully", + zap.String("session_id", resp.SessionID), + zap.String("invite_code", inviteCode), + zap.Int("num_server_parties", len(resp.SelectedServerParties))) + + c.JSON(http.StatusCreated, gin.H{ + "session_id": resp.SessionID, + "wallet_name": req.WalletName, + "invite_code": inviteCode, + "join_token": wildcardToken, // Token for external participants + "threshold_n": req.ThresholdN, + "threshold_t": req.ThresholdT, + "selected_server_parties": resp.SelectedServerParties, + "status": "waiting_for_participants", + "current_participants": len(resp.SelectedServerParties), // Server parties auto-joined + "required_participants": req.ThresholdN, + "expires_at": resp.ExpiresAt, + }) +} + +// ============================================ +// Join Co-Managed Wallet Session +// ============================================ + +// JoinSessionRequest represents the request for joining a session +type JoinSessionRequest struct { + PartyID string `json:"party_id" binding:"required"` // Participant's party ID + JoinToken string `json:"join_token" binding:"required"` // Join token (from invite) + ParticipantName string `json:"participant_name"` // Display name + DeviceType string `json:"device_type"` // Device type (pc, android, ios) + DeviceID string `json:"device_id"` // Device ID +} + +// JoinSession handles joining an existing co-managed session +func (h *CoManagedHTTPHandler) JoinSession(c *gin.Context) { + sessionID := c.Param("sessionId") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"}) + return + } + + // Validate session ID format + if _, err := uuid.Parse(sessionID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_id format"}) + return + } + + var req JoinSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Default device type + deviceType := req.DeviceType + if deviceType == "" { + deviceType = "pc" + } + + deviceID := req.DeviceID + if deviceID == "" { + deviceID = req.PartyID // Use party ID as device ID if not provided + } + + // Call session coordinator via gRPC + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + logger.Info("Joining co-managed session", + zap.String("session_id", sessionID), + zap.String("party_id", req.PartyID), + zap.String("participant_name", req.ParticipantName)) + + resp, err := h.sessionCoordinatorClient.JoinSession( + ctx, + sessionID, + req.PartyID, + req.JoinToken, + deviceType, + deviceID, + ) + + if err != nil { + logger.Error("Failed to join session", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if !resp.Success { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to join session"}) + return + } + + // Build other parties list + otherParties := make([]gin.H, 0, len(resp.OtherParties)) + for _, p := range resp.OtherParties { + otherParties = append(otherParties, gin.H{ + "party_id": p.PartyID, + "party_index": p.PartyIndex, + }) + } + + result := gin.H{ + "success": true, + "party_index": resp.PartyIndex, + "other_parties": otherParties, + } + + if resp.SessionInfo != nil { + result["session_info"] = gin.H{ + "session_id": resp.SessionInfo.SessionID, + "session_type": resp.SessionInfo.SessionType, + "threshold_n": resp.SessionInfo.ThresholdN, + "threshold_t": resp.SessionInfo.ThresholdT, + "status": resp.SessionInfo.Status, + "wallet_name": resp.SessionInfo.WalletName, + } + } + + logger.Info("Joined co-managed session successfully", + zap.String("session_id", sessionID), + zap.String("party_id", req.PartyID), + zap.Int32("party_index", resp.PartyIndex)) + + c.JSON(http.StatusOK, result) +} + +// ============================================ +// Get Session Status +// ============================================ + +// GetSessionStatus handles getting the status of a co-managed session +func (h *CoManagedHTTPHandler) GetSessionStatus(c *gin.Context) { + sessionID := c.Param("sessionId") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"}) + return + } + + // Validate session ID format + if _, err := uuid.Parse(sessionID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_id format"}) + return + } + + // Call session coordinator via gRPC + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID) + if err != nil { + logger.Error("Failed to get session status", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + result := gin.H{ + "session_id": sessionID, + "status": resp.Status, + "session_type": resp.SessionType, + "completed_parties": resp.CompletedParties, + "total_parties": resp.TotalParties, + } + + // Add public key if keygen completed + if resp.SessionType == "co_managed_keygen" && len(resp.PublicKey) > 0 { + result["public_key"] = hex.EncodeToString(resp.PublicKey) + } + + c.JSON(http.StatusOK, result) +} diff --git a/backend/mpc-system/services/account/adapters/output/grpc/session_coordinator_client.go b/backend/mpc-system/services/account/adapters/output/grpc/session_coordinator_client.go index a3d8b836..17081338 100644 --- a/backend/mpc-system/services/account/adapters/output/grpc/session_coordinator_client.go +++ b/backend/mpc-system/services/account/adapters/output/grpc/session_coordinator_client.go @@ -298,3 +298,167 @@ type DelegateShareInfo struct { PartyIndex int32 PartyID string } + +// CreateCoManagedSessionResponse contains the created co-managed session info +type CreateCoManagedSessionResponse struct { + SessionID string + InviteCode string + WalletName string + SelectedServerParties []string // Auto-selected server parties + JoinTokens map[string]string // Includes wildcard token for external parties + ExpiresAt int64 + ThresholdN int32 + ThresholdT int32 +} + +// CreateCoManagedKeygenSession creates a new co-managed keygen session +// This session waits for external participants to join via invite code +func (c *SessionCoordinatorClient) CreateCoManagedKeygenSession( + ctx context.Context, + walletName string, + inviteCode string, + thresholdN int32, + thresholdT int32, + persistentCount int32, // Number of server parties to auto-select + externalCount int32, // Number of external parties (Service Party Apps) + expiresInSeconds int64, +) (*CreateCoManagedSessionResponse, error) { + req := &coordinatorpb.CreateSessionRequest{ + SessionType: "co_managed_keygen", + ThresholdN: thresholdN, + ThresholdT: thresholdT, + Participants: nil, // External participants will join later + ExpiresInSeconds: expiresInSeconds, + PartyComposition: &coordinatorpb.PartyComposition{ + PersistentCount: persistentCount, + DelegateCount: 0, + TemporaryCount: externalCount, // External parties treated as temporary + }, + WalletName: walletName, + InviteCode: inviteCode, + } + + logger.Info("Sending CreateCoManagedKeygenSession gRPC request", + zap.String("session_type", "co_managed_keygen"), + zap.String("wallet_name", walletName), + zap.Int32("threshold_n", thresholdN), + zap.Int32("threshold_t", thresholdT), + zap.Int32("persistent_count", persistentCount), + zap.Int32("external_count", externalCount)) + + resp, err := c.client.CreateSession(ctx, req) + if err != nil { + logger.Error("CreateCoManagedKeygenSession gRPC call failed", zap.Error(err)) + return nil, fmt.Errorf("failed to create co-managed keygen session: %w", err) + } + + // Extract selected server parties + var serverParties []string + for partyID := range resp.JoinTokens { + if partyID != "*" { // Exclude wildcard token + serverParties = append(serverParties, partyID) + } + } + + logger.Info("CreateCoManagedKeygenSession gRPC call succeeded", + zap.String("session_id", resp.SessionId), + zap.Int("num_server_parties", len(serverParties))) + + return &CreateCoManagedSessionResponse{ + SessionID: resp.SessionId, + InviteCode: inviteCode, + WalletName: walletName, + SelectedServerParties: serverParties, + JoinTokens: resp.JoinTokens, + ExpiresAt: resp.ExpiresAt, + ThresholdN: thresholdN, + ThresholdT: thresholdT, + }, nil +} + +// JoinSessionResponse contains join session result +type JoinSessionResponse struct { + Success bool + PartyIndex int32 + SessionInfo *SessionInfoResponse + OtherParties []PartyInfoResponse +} + +// SessionInfoResponse contains session info from join response +type SessionInfoResponse struct { + SessionID string + SessionType string + ThresholdN int32 + ThresholdT int32 + Status string + WalletName string + InviteCode string + KeygenSessionID string +} + +// PartyInfoResponse contains party info +type PartyInfoResponse struct { + PartyID string + PartyIndex int32 +} + +// JoinSession joins an existing session +func (c *SessionCoordinatorClient) JoinSession( + ctx context.Context, + sessionID string, + partyID string, + joinToken string, + deviceType string, + deviceID string, +) (*JoinSessionResponse, error) { + req := &coordinatorpb.JoinSessionRequest{ + SessionId: sessionID, + PartyId: partyID, + JoinToken: joinToken, + DeviceInfo: &coordinatorpb.DeviceInfo{ + DeviceType: deviceType, + DeviceId: deviceID, + }, + } + + logger.Info("Sending JoinSession gRPC request", + zap.String("session_id", sessionID), + zap.String("party_id", partyID)) + + resp, err := c.client.JoinSession(ctx, req) + if err != nil { + logger.Error("JoinSession gRPC call failed", zap.Error(err)) + return nil, fmt.Errorf("failed to join session: %w", err) + } + + result := &JoinSessionResponse{ + Success: resp.Success, + PartyIndex: resp.PartyIndex, + } + + if resp.SessionInfo != nil { + result.SessionInfo = &SessionInfoResponse{ + SessionID: resp.SessionInfo.SessionId, + SessionType: resp.SessionInfo.SessionType, + ThresholdN: resp.SessionInfo.ThresholdN, + ThresholdT: resp.SessionInfo.ThresholdT, + Status: resp.SessionInfo.Status, + WalletName: resp.SessionInfo.WalletName, + InviteCode: resp.SessionInfo.InviteCode, + KeygenSessionID: resp.SessionInfo.KeygenSessionId, + } + } + + for _, p := range resp.OtherParties { + result.OtherParties = append(result.OtherParties, PartyInfoResponse{ + PartyID: p.PartyId, + PartyIndex: p.PartyIndex, + }) + } + + logger.Info("JoinSession gRPC call succeeded", + zap.Bool("success", resp.Success), + zap.Int32("party_index", resp.PartyIndex)) + + return result, nil +} diff --git a/backend/mpc-system/services/account/cmd/server/main.go b/backend/mpc-system/services/account/cmd/server/main.go index 2320fee3..0b9b6077 100644 --- a/backend/mpc-system/services/account/cmd/server/main.go +++ b/backend/mpc-system/services/account/cmd/server/main.go @@ -300,14 +300,18 @@ func startHTTPServer( }) }) + // Create co-managed wallet handler (independent from existing functionality) + coManagedHandler := httphandler.NewCoManagedHTTPHandler(sessionCoordinatorClient) + // Configure authentication middleware // Skip paths that don't require authentication authConfig := middleware.AuthConfig{ JWTService: jwtService, SkipPaths: []string{ "/health", - "/api/v1/auth/*", // Auth endpoints (login, refresh, challenge) + "/api/v1/auth/*", // Auth endpoints (login, refresh, challenge) "/api/v1/accounts/from-keygen", // Internal API from coordinator + "/api/v1/co-managed/*", // Co-managed wallet API (public for Service Party App) }, AllowAnonymous: false, } @@ -317,6 +321,9 @@ func startHTTPServer( api.Use(middleware.BearerAuth(authConfig)) httpHandler.RegisterRoutes(api) + // Register co-managed wallet routes (public API) + coManagedHandler.RegisterRoutes(api) + logger.Info("Starting HTTP server", zap.Int("port", cfg.Server.HTTPPort), zap.String("environment", cfg.Server.Environment), diff --git a/backend/mpc-system/services/service-party-app/electron/modules/grpc-client.ts b/backend/mpc-system/services/service-party-app/electron/modules/grpc-client.ts index 190b3fd3..3ac1881a 100644 --- a/backend/mpc-system/services/service-party-app/electron/modules/grpc-client.ts +++ b/backend/mpc-system/services/service-party-app/electron/modules/grpc-client.ts @@ -80,6 +80,10 @@ interface SessionEvent { /** * gRPC 客户端 - 连接到 Message Router + * + * 连接地址格式: + * - 开发环境: localhost:50051 (不加密) + * - 生产环境: mpc-grpc.szaiai.com:443 (TLS 加密) */ export class GrpcClient extends EventEmitter { private client: grpc.Client | null = null; @@ -95,8 +99,10 @@ export class GrpcClient extends EventEmitter { /** * 连接到 Message Router + * @param address 完整地址,格式: host:port (例如 mpc-grpc.szaiai.com:443 或 localhost:50051) + * @param useTLS 是否使用 TLS 加密 (默认: 自动检测,端口 443 使用 TLS) */ - async connect(host: string, port: number): Promise { + async connect(address: string, useTLS?: boolean): Promise { return new Promise((resolve, reject) => { const proto = grpc.loadPackageDefinition(packageDefinition) as ProtoPackage; const MessageRouter = proto.mpc?.router?.v1?.MessageRouter; @@ -106,9 +112,26 @@ export class GrpcClient extends EventEmitter { return; } + // 解析地址,如果没有端口则默认使用 443 + let targetAddress = address; + if (!address.includes(':')) { + targetAddress = `${address}:443`; + } + + // 自动检测是否使用 TLS: 端口 443 或显式指定 + const port = parseInt(targetAddress.split(':')[1], 10); + const shouldUseTLS = useTLS !== undefined ? useTLS : (port === 443); + + // 创建凭证 + const credentials = shouldUseTLS + ? grpc.credentials.createSsl() // TLS 加密 (生产环境) + : grpc.credentials.createInsecure(); // 不加密 (开发环境) + + console.log(`Connecting to Message Router: ${targetAddress} (TLS: ${shouldUseTLS})`); + this.client = new MessageRouter( - `${host}:${port}`, - grpc.credentials.createInsecure() + targetAddress, + credentials ) as grpc.Client; // 等待连接就绪 diff --git a/backend/mpc-system/services/service-party-app/src/pages/Create.module.css b/backend/mpc-system/services/service-party-app/src/pages/Create.module.css index b531ab17..48681d4a 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/Create.module.css +++ b/backend/mpc-system/services/service-party-app/src/pages/Create.module.css @@ -35,6 +35,44 @@ gap: var(--spacing-lg); } +/* Info Box */ +.infoBox { + display: flex; + gap: var(--spacing-md); + padding: var(--spacing-md); + background-color: rgba(0, 90, 156, 0.08); + border: 1px solid rgba(0, 90, 156, 0.2); + border-radius: var(--radius-md); +} + +.infoIcon { + font-size: 20px; + flex-shrink: 0; +} + +.infoContent { + font-size: 13px; + color: var(--text-primary); + line-height: 1.5; +} + +.infoContent strong { + color: var(--primary-color); +} + +.infoList { + margin: var(--spacing-sm) 0 0; + padding-left: var(--spacing-md); +} + +.infoList li { + margin-bottom: 4px; +} + +.infoList li:last-child { + margin-bottom: 0; +} + .inputGroup { display: flex; flex-direction: column; diff --git a/backend/mpc-system/services/service-party-app/src/pages/Create.tsx b/backend/mpc-system/services/service-party-app/src/pages/Create.tsx index 22b8ff89..76e7226a 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/Create.tsx +++ b/backend/mpc-system/services/service-party-app/src/pages/Create.tsx @@ -91,10 +91,24 @@ export default function Create() {

创建共管钱包

-

设置钱包参数并邀请其他参与方

+

3-of-5 混合托管模式 - 设置钱包参数并邀请参与方

{step === 'config' && (
+ {/* 混合托管说明 */} +
+
ℹ️
+
+ 混合托管模式说明 +
    +
  • 5 个密钥份额: 2 个平台备份 + 3 个用户持有
  • +
  • 正常签名: 仅需 3 位用户共同签名
  • +
  • 密钥恢复: 允许 2 位用户丢失密钥,可使用平台备份轮换
  • +
  • 安全保障: 平台备份仅用于紧急恢复,不参与日常签名
  • +
+
+
+

- 需要 {thresholdT} 个参与方共同签名才能执行交易 + 需要 {thresholdT} 个参与方共同签名才能执行交易 (其中 2 个由平台托管用于备份)

diff --git a/backend/mpc-system/services/service-party-app/src/pages/Home.module.css b/backend/mpc-system/services/service-party-app/src/pages/Home.module.css index 0b208e5c..afd0a793 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/Home.module.css +++ b/backend/mpc-system/services/service-party-app/src/pages/Home.module.css @@ -213,3 +213,163 @@ border-color: var(--primary-color); background-color: rgba(0, 90, 156, 0.05); } + +.dangerButton { + color: #dc3545; + margin-left: var(--spacing-sm); +} + +.dangerButton:hover { + border-color: #dc3545; + background-color: rgba(220, 53, 69, 0.05); +} + +/* Address Section */ +.addressSection { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md); + margin-bottom: var(--spacing-md); + background-color: var(--background-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: background-color 0.2s; +} + +.addressSection:hover { + background-color: rgba(0, 90, 156, 0.05); +} + +.qrPreview { + flex-shrink: 0; + padding: var(--spacing-xs); + background: white; + border-radius: var(--radius-sm); +} + +.addressInfo { + display: flex; + flex-direction: column; + gap: 4px; + overflow: hidden; +} + +.addressLabel { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.addressValue { + font-size: 13px; + color: var(--text-primary); + font-family: monospace; +} + +.addressHint { + font-size: 11px; + color: var(--primary-color); +} + +/* Modal */ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background-color: var(--surface-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + max-width: 400px; + width: 90%; + overflow: hidden; +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border-color); +} + +.modalTitle { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.modalClose { + background: none; + border: none; + font-size: 24px; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + line-height: 1; +} + +.modalClose:hover { + color: var(--text-primary); +} + +.modalBody { + padding: var(--spacing-lg); + display: flex; + flex-direction: column; + align-items: center; +} + +.qrContainer { + padding: var(--spacing-md); + background: white; + border-radius: var(--radius-md); + margin-bottom: var(--spacing-lg); +} + +.fullAddress { + text-align: center; + margin-bottom: var(--spacing-lg); +} + +.addressLabelLarge { + display: block; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: var(--spacing-sm); +} + +.addressValueLarge { + display: block; + font-size: 14px; + color: var(--text-primary); + font-family: monospace; + word-break: break-all; + background-color: var(--background-color); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-sm); +} + +.modalActions { + display: flex; + gap: var(--spacing-md); + width: 100%; +} + +.modalActions .primaryButton, +.modalActions .secondaryButton { + flex: 1; + text-align: center; + text-decoration: none; +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Home.tsx b/backend/mpc-system/services/service-party-app/src/pages/Home.tsx index 5466d5fc..57a0ebec 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/Home.tsx +++ b/backend/mpc-system/services/service-party-app/src/pages/Home.tsx @@ -1,6 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; +import { QRCodeSVG } from 'qrcode.react'; import styles from './Home.module.css'; +import { deriveEvmAddress, formatAddress, getKavaExplorerUrl } from '../utils/address'; interface ShareItem { id: string; @@ -14,10 +16,30 @@ interface ShareItem { }; } +interface ShareWithAddress extends ShareItem { + evmAddress?: string; +} + export default function Home() { const navigate = useNavigate(); - const [shares, setShares] = useState([]); + const [shares, setShares] = useState([]); const [loading, setLoading] = useState(true); + const [selectedShare, setSelectedShare] = useState(null); + const [showQrModal, setShowQrModal] = useState(false); + + const deriveAddresses = useCallback(async (shareList: ShareItem[]): Promise => { + const sharesWithAddresses: ShareWithAddress[] = []; + for (const share of shareList) { + try { + const evmAddress = await deriveEvmAddress(share.publicKey); + sharesWithAddresses.push({ ...share, evmAddress }); + } catch (error) { + console.warn(`Failed to derive address for share ${share.id}:`, error); + sharesWithAddresses.push({ ...share }); + } + } + return sharesWithAddresses; + }, []); useEffect(() => { loadShares(); @@ -25,19 +47,25 @@ export default function Home() { const loadShares = async () => { try { + let shareList: ShareItem[] = []; + // 检测是否在 Electron 环境中 if (window.electronAPI) { const result = await window.electronAPI.storage.listShares(); - if (result.success) { - setShares(result.data as ShareItem[]); + if (result.success && result.data) { + shareList = result.data as ShareItem[]; } } else { // 浏览器环境,使用 localStorage 或 API const stored = localStorage.getItem('shares'); if (stored) { - setShares(JSON.parse(stored)); + shareList = JSON.parse(stored); } } + + // 为每个 share 派生 EVM 地址 + const sharesWithAddresses = await deriveAddresses(shareList); + setShares(sharesWithAddresses); } catch (error) { console.error('Failed to load shares:', error); } finally { @@ -51,9 +79,17 @@ export default function Home() { try { if (window.electronAPI) { + // 先让用户选择保存位置 + const savePath = await window.electronAPI.dialog.saveFile( + `share-backup-${id.slice(0, 8)}.dat`, + [{ name: 'Share Backup', extensions: ['dat'] }] + ); + + if (!savePath) return; + const result = await window.electronAPI.storage.exportShare(id, password); if (result.success && result.data) { - // 触发下载 + // 通过 IPC 写入文件 const blob = new Blob([result.data], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -61,6 +97,9 @@ export default function Home() { a.download = `share-backup-${id.slice(0, 8)}.dat`; a.click(); URL.revokeObjectURL(url); + alert('备份文件导出成功!'); + } else { + alert('导出失败: ' + (result.error || '未知错误')); } } } catch (error) { @@ -68,6 +107,34 @@ export default function Home() { } }; + const handleDelete = async (id: string) => { + const confirmed = window.confirm('确定要删除这个钱包吗?删除后无法恢复,请确保已备份。'); + if (!confirmed) return; + + try { + if (window.electronAPI) { + const result = await window.electronAPI.storage.deleteShare(id); + if (result.success) { + setShares(shares.filter((s) => s.id !== id)); + } else { + alert('删除失败: ' + (result.error || '未知错误')); + } + } + } catch (error) { + alert('删除失败: ' + (error as Error).message); + } + }; + + const handleShowQr = (share: ShareWithAddress) => { + setSelectedShare(share); + setShowQrModal(true); + }; + + const handleCopyAddress = (address: string) => { + navigator.clipboard.writeText(address); + alert('地址已复制到剪贴板'); + }; + const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', @@ -78,13 +145,6 @@ export default function Home() { }); }; - const truncateKey = (key: string) => { - if (key.length > 16) { - return `${key.slice(0, 8)}...${key.slice(-8)}`; - } - return key; - }; - if (loading) { return (
@@ -98,7 +158,7 @@ export default function Home() {

我的共管钱包

-

管理您参与的多方共管钱包

+

管理您参与的多方共管钱包 (3-of-5 混合托管)

{shares.length === 0 ? ( @@ -134,12 +194,30 @@ export default function Home() {
-
- 公钥 - - {truncateKey(share.publicKey)} - -
+ {/* 地址区域 - 可点击显示二维码 */} + {share.evmAddress && ( +
handleShowQr(share)} + > +
+ +
+
+ Kava EVM 地址 + + {formatAddress(share.evmAddress)} + + 点击查看完整二维码 +
+
+ )} +
参与方 @@ -168,11 +246,66 @@ export default function Home() { > 导出备份 +
))}
)} + + {/* 二维码弹窗 */} + {showQrModal && selectedShare && ( +
setShowQrModal(false)}> +
e.stopPropagation()}> +
+

{selectedShare.walletName}

+ +
+
+
+ +
+
+ Kava EVM 地址 + + {selectedShare.evmAddress} + +
+
+ + + 在区块浏览器查看 + +
+
+
+
+ )}
); } diff --git a/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx b/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx index eef92f66..2d684cf6 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx +++ b/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx @@ -12,7 +12,7 @@ export default function Settings() { const navigate = useNavigate(); const [settings, setSettings] = useState({ - messageRouterUrl: 'localhost:50051', + messageRouterUrl: 'mpc-grpc.szaiai.com:443', // 生产环境默认地址 autoBackup: false, backupPath: '', }); @@ -106,7 +106,7 @@ export default function Settings() { type="text" value={settings.messageRouterUrl} onChange={(e) => setSettings(prev => ({ ...prev, messageRouterUrl: e.target.value }))} - placeholder="localhost:50051" + placeholder="mpc-grpc.szaiai.com:443" className={styles.input} />

- 输入 Message Router 服务的 gRPC 地址 + 输入 Message Router 服务的 gRPC 地址 (生产环境: mpc-grpc.szaiai.com:443)