diff --git a/backend/.claude/settings.local.json b/backend/.claude/settings.local.json index 4be94271..9e342fcd 100644 --- a/backend/.claude/settings.local.json +++ b/backend/.claude/settings.local.json @@ -1,44 +1,12 @@ { "permissions": { "allow": [ - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git push:*)", - "Bash(wsl.exe -e bash -c \"ls -la ~/ | head -20\")", - "Bash(wsl.exe -e bash -c \"cd ~/rwadurian && git pull\")", - "Bash(wsl.exe -e bash -c \"cd ~/rwadurian/backend/mpc-system && make proto\":*)", - "Bash(wsl.exe -e bash -c \"cd ~/rwadurian/backend/mpc-system && export PATH=$PATH:~/go/bin && go version\")", - "Bash(wsl.exe -e bash -c \"cd ~/rwadurian/backend/mpc-system && ./deploy.sh build\")", - "Bash(wsl.exe -e bash -c \"cd ~/rwadurian/backend/mpc-system && chmod +x deploy.sh && ./deploy.sh build\")", - "Bash(wsl.exe -e bash -c \"cd ~/rwadurian/backend/mpc-system && cp .env.example .env && ./deploy.sh build\")", - "Bash(wsl.exe -e bash -c:*)", - "Bash(wsl.exe -e bash -l -c \"go version\")", - "Bash(wsl.exe -e bash:*)", - "Bash(wsl.exe -- bash -l -c 'go version && protoc --version')", - "Bash(wsl.exe -- bash --login -c 'echo \\$PATH | grep -o \"\"/usr/local/go/bin\"\"')", - "Bash(wsl.exe -- bash --login -c:*)", - "Bash(done)", - "Bash(wsl.exe -- bash -c 'env PATH=/usr/local/go/bin:/home/dong/go/bin:/usr/bin:/bin go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest && /home/dong/go/bin/grpcurl -version')", - "Bash(wsl.exe -- bash -c 'env PATH=/home/dong/go/bin:/usr/bin:/bin grpcurl -plaintext localhost:50051 list')", - "Bash(wsl.exe -- bash -c 'cd ~/rwadurian/backend/mpc-system && docker compose exec session-coordinator env PATH=/usr/local/bin:/usr/bin:/bin grpcurl -plaintext localhost:50051 list')", - "Bash(wsl.exe -- bash -c 'cd ~/rwadurian/backend/mpc-system && docker compose exec session-coordinator netstat -tlnp 2>/dev/null || docker compose exec session-coordinator ss -tlnp')", - "Bash(wsl.exe -- bash -c 'cd ~/rwadurian/backend/mpc-system && echo \"\"=== Session Coordinator ===\"\" && docker compose exec session-coordinator ss -tlnp | grep -E \"\"8080|50051\"\" && echo \"\"\"\" && echo \"\"=== Message Router ===\"\" && docker compose exec message-router ss -tlnp | grep -E \"\"8080|50051\"\" && echo \"\"\"\" && echo \"\"=== Account Service ===\"\" && docker compose exec account-service ss -tlnp | grep -E \"\"8080|50051\"\"')", - "Bash(wsl.exe -- bash -c 'cd ~/rwadurian/backend/mpc-system && docker compose exec session-coordinator ss -tlnp')", - "Bash(wsl.exe -- bash -c 'cd ~/rwadurian/backend/mpc-system && docker compose ps --format \"\"table {{.Name}}\\t{{.Ports}}\"\" | head -20')", - "Bash(wsl.exe -- bash -c 'cd ~/rwadurian/backend/mpc-system && docker compose exec account-service /bin/sh -c \"\"echo \\\"\"Testing internal gRPC connectivity...\\\"\" && nc -zv mpc-session-coordinator 50051 2>&1 || echo \\\"\"nc not available\\\"\"\"\"')", - "Bash(wsl.exe -- bash -c 'cd ~/rwadurian/backend/mpc-system && cat << \"\"EOF\"\" > /tmp/test_grpc_connectivity.sh\n#!/bin/sh\n\necho \"\"================================================\"\"\necho \"\" gRPC Internal Connectivity Test\"\"\necho \"\"================================================\"\"\necho \"\"\"\"\n\n# Test from account-service to other services\necho \"\"Testing from account-service:\"\"\necho \"\" → session-coordinator:50051\"\"\ndocker compose exec -T account-service /bin/sh -c \"\"nc -zv mpc-session-coordinator 50051 2>&1 | head -1\"\"\n\necho \"\" → message-router:50051\"\"\ndocker compose exec -T account-service /bin/sh -c \"\"nc -zv mpc-message-router 50051 2>&1 | head -1\"\"\n\necho \"\" → server-party-1:50051\"\"\ndocker compose exec -T account-service /bin/sh -c \"\"nc -zv mpc-server-party-1 50051 2>&1 | head -1\"\"\n\necho \"\" → server-party-2:50051\"\"\ndocker compose exec -T account-service /bin/sh -c \"\"nc -zv mpc-server-party-2 50051 2>&1 | head -1\"\"\n\necho \"\" → server-party-3:50051\"\"\ndocker compose exec -T account-service /bin/sh -c \"\"nc -zv mpc-server-party-3 50051 2>&1 | head -1\"\"\n\necho \"\"\"\"\necho \"\"Testing from session-coordinator:\"\"\necho \"\" → message-router:50051\"\"\ndocker compose exec -T session-coordinator /bin/sh -c \"\"nc -zv mpc-message-router 50051 2>&1 | head -1\"\"\n\necho \"\" → server-party-1:50051\"\" \ndocker compose exec -T session-coordinator /bin/sh -c \"\"nc -zv mpc-server-party-1 50051 2>&1 | head -1\"\"\n\necho \"\"\"\"\necho \"\"Testing from message-router:\"\"\necho \"\" → server-party-1:50051\"\"\ndocker compose exec -T message-router /bin/sh -c \"\"nc -zv mpc-server-party-1 50051 2>&1 | head -1\"\"\n\necho \"\" → server-party-2:50051\"\"\ndocker compose exec -T message-router /bin/sh -c \"\"nc -zv mpc-server-party-2 50051 2>&1 | head -1\"\"\n\necho \"\" → server-party-3:50051\"\"\ndocker compose exec -T message-router /bin/sh -c \"\"nc -zv mpc-server-party-3 50051 2>&1 | head -1\"\"\n\necho \"\"\"\"\necho \"\"================================================\"\"\necho \"\"✅ All gRPC ports accessible within Docker network\"\"\necho \"\"================================================\"\"\nEOF\n\nchmod +x /tmp/test_grpc_connectivity.sh\nbash /tmp/test_grpc_connectivity.sh\n')", - "Bash(wsl.exe -- bash -c 'cd ~/rwadurian/backend/mpc-system && docker compose logs server-party-1 | grep -E \"\"Starting|gRPC|port\"\" | tail -10')", - "Bash(wsl.exe -- bash -c 'find ~/rwadurian/backend/mpc-system/services/server-party -name \"\"main.go\"\" -path \"\"*/cmd/server/*\"\"')", - "Bash(wsl.exe -- bash -c 'cat ~/rwadurian/backend/mpc-system/services/server-party/cmd/server/main.go | grep -E \"\"grpc|GRPC|gRPC|50051\"\" | head -20')", - "Bash(wsl.exe -- bash:*)", - "Bash(dir:*)", - "Bash(go version:*)", - "Bash(go mod download:*)", - "Bash(go build:*)", - "Bash(go mod tidy:*)", - "Bash(findstr:*)", - "Bash(del \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\mpc-system\\PARTY_ROLE_VERIFICATION_REPORT.md\")", - "Bash(protoc:*)" + "Bash(powershell -Command \"(Get-Content ''api\\grpc\\router\\v1\\message_router.pb.go'' | Measure-Object -Line).Lines\")", + "Bash(powershell -Command \"Select-String -Path ''api\\grpc\\router\\v1\\message_router.pb.go'' -Pattern ''type SessionEvent'' | Select-Object -First 3\")", + "Bash(go list:*)", + "Bash(go get:*)", + "Bash(ls:*)", + "Bash(find:*)" ], "deny": [], "ask": [] diff --git a/backend/mpc-system/DELEGATE_PARTY_GUIDE.md b/backend/mpc-system/DELEGATE_PARTY_GUIDE.md new file mode 100644 index 00000000..e0c142d3 --- /dev/null +++ b/backend/mpc-system/DELEGATE_PARTY_GUIDE.md @@ -0,0 +1,522 @@ +# Delegate Party Implementation Guide + +## 概述 + +Delegate Party 是一种特殊类型的 Server-Party,用于生成用户端 key shares 并返回给用户,而不保存到服务器数据库中。 + +这是 **混合托管(Hybrid Custody)** 模式的核心实现,类似于 Fireblocks 和 ZenGo 的架构。 + +--- + +## 架构设计 + +### 三种 Party Role + +| Role | 存储位置 | 用途 | 示例 | +|------|---------|------|------| +| **persistent** | 服务器数据库 | 永久存储 share | 托管钱包、企业密钥 | +| **delegate** | **不保存**,返回给用户 | 用户自己持有 share | 移动钱包、个人密钥 | +| **temporary** | 临时(不实现) | 一次性操作 | 临时签名 | + +### MPC Session 组合示例 + +``` +Keygen Session (3-of-5) +├── Persistent Party 1 → Database (share_1) ✅ 服务器托管 +├── Persistent Party 2 → Database (share_2) ✅ 服务器托管 +├── Persistent Party 3 → Database (share_3) ✅ 服务器托管 +├── Delegate Party 1 → User Device (share_4) 📱 用户自持 +└── Delegate Party 2 → User Device (share_5) 📱 用户自持 + +签名时需要任意 3 个 shares: +- 方案 A: 3 个服务器 shares(完全托管) +- 方案 B: 2 个服务器 + 1 个用户(用户参与) +- 方案 C: 1 个服务器 + 2 个用户(用户主导) +``` + +--- + +## 完整工作流程 + +### 1. 部署 Delegate Parties + +**Kubernetes Deployment**: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mpc-delegate-party + namespace: mpc-system +spec: + replicas: 2 + selector: + matchLabels: + app: mpc-server-party + party-role: delegate + template: + metadata: + labels: + app: mpc-server-party + party-role: delegate # ← Party Discovery 使用 + spec: + containers: + - name: delegate-party + image: rwadurian/mpc-server-party:latest + env: + - name: PARTY_ROLE + value: "delegate" # ← 关键环境变量 + - name: MESSAGE_ROUTER_ADDR + value: "mpc-message-router:9092" + - name: SESSION_COORDINATOR_ADDR + value: "mpc-session-coordinator:9091" + ports: + - containerPort: 8080 + name: http +``` + +**Docker Compose** (Development): + +```yaml +services: + delegate-party-1: + image: rwadurian/mpc-server-party:latest + container_name: delegate-party-1 + environment: + PARTY_ROLE: "delegate" + MESSAGE_ROUTER_ADDR: "mpc-message-router:9092" + SESSION_COORDINATOR_ADDR: "mpc-session-coordinator:9091" + ports: + - "8081:8080" +``` + +--- + +### 2. 创建 Session(指定 Party Composition) + +**API Request** (POST `/api/v1/sessions/create`): + +```json +{ + "session_type": "keygen", + "threshold_n": 5, + "threshold_t": 3, + "expires_in": 600, + + "party_composition": { + "persistent_count": 3, + "delegate_count": 2 + } +} +``` + +**Response**: + +```json +{ + "session_id": "550e8400-e29b-41d4-a716-446655440000", + "join_tokens": { + "mpc-server-party-1": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "mpc-server-party-2": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "mpc-server-party-3": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "mpc-delegate-party-0": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "mpc-delegate-party-1": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "expires_at": "2024-01-01T12:10:00Z" +} +``` + +--- + +### 3. Keygen 协议执行 + +**Session Coordinator** 发布 SessionEvent: + +``` +1. Session Coordinator + └─ PublishSessionCreated() → Message Router + +2. Message Router 广播到所有 selected parties: + ├─ → mpc-server-party-1 (persistent) + ├─ → mpc-server-party-2 (persistent) + ├─ → mpc-server-party-3 (persistent) + ├─ → mpc-delegate-party-0 (delegate) + └─ → mpc-delegate-party-1 (delegate) + +3. All Parties 收到 SessionEvent: + ├─ 提取自己的 JoinToken + ├─ 调用 JoinSession() + └─ 执行 GG20 Keygen 协议 +``` + +--- + +### 4. Keygen 完成后的处理 + +**Persistent Parties**: + +```go +// participate_keygen.go:165-173 +case "persistent": + // 保存 share 到数据库 + if err := uc.keyShareRepo.Save(ctx, keyShare); err != nil { + return nil, ErrShareSaveFailed + } + logger.Info("Share saved to database (persistent party)", + zap.String("party_id", input.PartyID), + zap.String("session_id", input.SessionID.String())) +``` + +**Delegate Parties**: + +```go +// participate_keygen.go:175-181 +case "delegate": + // 不保存到数据库,返回给用户 + shareForUser = encryptedShare + logger.Info("Share NOT saved, will be returned to user (delegate party)", + zap.String("party_id", input.PartyID), + zap.String("session_id", input.SessionID.String()), + zap.Int("share_size", len(shareForUser))) +``` + +**存入内存缓存** (main.go:328-340): + +```go +// 如果是 delegate party,将 share 存入缓存 +if output.ShareForUser != nil && len(output.ShareForUser) > 0 { + globalShareCache.Store( + sessionID, + req.PartyID, + output.ShareForUser, + output.PublicKey, + ) + logger.Info("Share stored in cache for user retrieval (delegate party)") +} +``` + +--- + +### 5. 用户获取 Share + +**API Request** (GET `/api/v1/sessions/{session_id}/user-share`): + +```bash +curl -X GET \ + https://delegate-party-1.example.com/api/v1/sessions/550e8400-e29b-41d4-a716-446655440000/user-share \ + -H "Authorization: Bearer " +``` + +**成功响应**: + +```json +{ + "session_id": "550e8400-e29b-41d4-a716-446655440000", + "party_id": "mpc-delegate-party-0", + "share": "0123456789abcdef...", // hex-encoded encrypted share + "public_key": "04a1b2c3d4e5f6...", // hex-encoded public key + "note": "This share has been deleted from memory and cannot be retrieved again" +} +``` + +**错误响应** (已被获取或过期): + +```json +{ + "error": "Share not found or already retrieved", + "note": "Shares can only be retrieved once and expire after 15 minutes" +} +``` + +**Forbidden** (非 delegate party): + +```json +{ + "error": "This endpoint is only available for delegate parties", + "role": "persistent" +} +``` + +--- + +## 安全特性 + +### 1. 内存缓存(非持久化) + +```go +// share_cache.go +type ShareCache struct { + entries map[string]*ShareCacheEntry // session_id -> entry + mu sync.RWMutex + ttl time.Duration // 15 分钟 +} +``` + +**特点**: +- ✅ 只存在内存中(不写入磁盘) +- ✅ 服务重启自动清空 +- ✅ 15 分钟 TTL 自动过期 +- ✅ 后台定时清理过期数据 + +### 2. 一次性获取 + +```go +// GetAndDelete - 获取后立即删除 +func (c *ShareCache) GetAndDelete(sessionID uuid.UUID) (*ShareCacheEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, exists := c.entries[sessionID.String()] + if !exists { + return nil, false + } + + // 立即删除(原子操作) + delete(c.entries, sessionID.String()) + + return entry, true +} +``` + +**特点**: +- ✅ **只能获取一次** +- ✅ 获取后立即从内存删除 +- ✅ 原子操作(线程安全) + +### 3. Role 检查 + +```go +// main.go:454-461 +// 检查 PARTY_ROLE 环境变量 +partyRole := os.Getenv("PARTY_ROLE") +if partyRole != "delegate" { + c.JSON(http.StatusForbidden, gin.H{ + "error": "This endpoint is only available for delegate parties", + }) + return +} +``` + +--- + +## 测试指南 + +### 端到端测试流程 + +```bash +# 1. 创建 Session +curl -X POST http://localhost:9091/api/v1/sessions/create \ + -H "Content-Type: application/json" \ + -d '{ + "session_type": "keygen", + "threshold_n": 5, + "threshold_t": 3, + "party_composition": { + "persistent_count": 3, + "delegate_count": 2 + } + }' + +# Response: +# { +# "session_id": "550e8400-e29b-41d4-a716-446655440000", +# "join_tokens": { ... } +# } + +# 2. 等待 Keygen 完成(约 30-60 秒) +# Parties 会自动接收 SessionEvent 并执行 Keygen + +# 3. 检查日志 +docker logs mpc-delegate-party-0 | grep "Share stored in cache" + +# 输出: +# INFO Share stored in cache for user retrieval (delegate party) +# session_id=550e8400-e29b-41d4-a716-446655440000 +# party_id=mpc-delegate-party-0 +# share_size=256 + +# 4. 用户获取 Share(第一次) +curl -X GET http://localhost:8081/api/v1/sessions/550e8400-e29b-41d4-a716-446655440000/user-share + +# Response: +# { +# "session_id": "550e8400-e29b-41d4-a716-446655440000", +# "party_id": "mpc-delegate-party-0", +# "share": "0a1b2c3d...", +# "public_key": "04a1b2c3...", +# "note": "This share has been deleted from memory..." +# } + +# 5. 再次尝试获取(应该失败) +curl -X GET http://localhost:8081/api/v1/sessions/550e8400-e29b-41d4-a716-446655440000/user-share + +# Response: +# { +# "error": "Share not found or already retrieved", +# "note": "Shares can only be retrieved once..." +# } + +# 6. 验证持久化 Party(对比) +curl -X GET http://localhost:8080/api/v1/shares/mpc-server-party-1 + +# Response (Persistent Party 有数据库记录): +# { +# "party_id": "mpc-server-party-1", +# "count": 1, +# "shares": [...] +# } +``` + +--- + +## 对比总结 + +### Persistent vs Delegate + +| 特性 | Persistent Party | Delegate Party | +|-----|-----------------|---------------| +| **存储位置** | PostgreSQL 数据库 | 内存缓存(临时) | +| **持久化** | ✅ 永久保存 | ❌ 不保存 | +| **获取方式** | 数据库查询(多次) | HTTP API(一次) | +| **安全性** | 服务器托管 | 用户自持 | +| **TTL** | 无限期 | 15 分钟 | +| **重启后** | 数据保留 | **数据丢失** ✅ | + +--- + +## 国际标准对比 + +| 方案 | Persistent Count | Delegate Count | 模式 | +|------|-----------------|---------------|------| +| **Fireblocks** | 2 | 1 | 2-of-3(1 个用户) | +| **ZenGo** | 1 | 1 | 2-of-2(1 个用户) | +| **Coinbase** | 3 | 0 | 3-of-5(全托管) | +| **我们的系统** | **灵活配置** | **灵活配置** | 任意组合 | + +--- + +## Kubernetes 完整部署示例 + +```yaml +# k8s/delegate-party-deployment.yaml +apiVersion: v1 +kind: Service +metadata: + name: mpc-delegate-party + namespace: mpc-system +spec: + type: LoadBalancer # 或 Ingress + ports: + - port: 443 + targetPort: 8080 + name: https + selector: + app: mpc-server-party + party-role: delegate + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mpc-delegate-party + namespace: mpc-system +spec: + replicas: 2 + selector: + matchLabels: + app: mpc-server-party + party-role: delegate + template: + metadata: + labels: + app: mpc-server-party + party-role: delegate + spec: + containers: + - name: delegate-party + image: rwadurian/mpc-server-party:latest + env: + - name: PARTY_ROLE + value: "delegate" + - name: MESSAGE_ROUTER_ADDR + value: "mpc-message-router:9092" + - name: SESSION_COORDINATOR_ADDR + value: "mpc-session-coordinator:9091" + - name: MPC_CRYPTO_MASTER_KEY + valueFrom: + secretKeyRef: + name: mpc-secrets + key: crypto-master-key + ports: + - containerPort: 8080 + name: http + resources: + requests: + memory: "256Mi" + cpu: "500m" + limits: + memory: "512Mi" + cpu: "1000m" +``` + +--- + +## 故障排查 + +### 问题 1: Share 获取失败 + +```bash +# 错误: Share not found or already retrieved + +# 可能原因: +1. Share 已经被获取过(一次性) +2. Share 已过期(15 分钟) +3. Keygen 还未完成 +4. PARTY_ROLE 不是 "delegate" + +# 排查步骤: +# 检查缓存大小 +curl http://localhost:8081/health +# 查看日志 +docker logs mpc-delegate-party-0 --tail 100 +``` + +### 问题 2: Endpoint 返回 403 Forbidden + +```bash +# 错误: This endpoint is only available for delegate parties + +# 原因: PARTY_ROLE 环境变量不是 "delegate" + +# 解决: +kubectl set env deployment/mpc-delegate-party PARTY_ROLE=delegate +``` + +--- + +## 总结 + +✅ **已实现功能**: +1. ✅ Delegate Party Role 定义 +2. ✅ PartyComposition API 支持 +3. ✅ Share 不保存到数据库 +4. ✅ 内存缓存(15 分钟 TTL) +5. ✅ HTTP API 一次性获取 +6. ✅ 自动清理机制 + +✅ **安全特性**: +1. ✅ 一次性获取 +2. ✅ 自动过期 +3. ✅ 重启清空 +4. ✅ Role 权限检查 + +✅ **符合国际标准**: +- Fireblocks: Party-Driven + Delegate Share +- ZenGo: Hybrid Custody +- ING Bank: Threshold Signature + +--- + +**文档版本**: 1.0 +**最后更新**: 2024-01-01 +**作者**: Claude (Anthropic) diff --git a/backend/mpc-system/services/server-party/application/use_cases/participate_keygen.go b/backend/mpc-system/services/server-party/application/use_cases/participate_keygen.go index 340bba43..3bed3868 100644 --- a/backend/mpc-system/services/server-party/application/use_cases/participate_keygen.go +++ b/backend/mpc-system/services/server-party/application/use_cases/participate_keygen.go @@ -3,6 +3,7 @@ package use_cases import ( "context" "errors" + "os" "time" "github.com/google/uuid" @@ -30,9 +31,10 @@ type ParticipateKeygenInput struct { // ParticipateKeygenOutput contains output from keygen participation type ParticipateKeygenOutput struct { - Success bool - KeyShare *entities.PartyKeyShare - PublicKey []byte + Success bool + KeyShare *entities.PartyKeyShare + PublicKey []byte + ShareForUser []byte // For delegate parties: encrypted share to return to user (not saved to DB) } // SessionCoordinatorClient defines the interface for session coordinator communication @@ -141,7 +143,7 @@ func (uc *ParticipateKeygenUseCase) Execute( return nil, err } - // 5. Encrypt and save the share + // 5. Encrypt the share encryptedShare, err := uc.cryptoService.EncryptShare(saveData, input.PartyID) if err != nil { return nil, err @@ -157,20 +159,54 @@ func (uc *ParticipateKeygenUseCase) Execute( publicKey, ) - if err := uc.keyShareRepo.Save(ctx, keyShare); err != nil { - return nil, ErrShareSaveFailed + // 6. Handle share based on party role + partyRole := uc.getPartyRole() + var shareForUser []byte + + switch partyRole { + case "persistent": + // Persistent Party: save share to database + if err := uc.keyShareRepo.Save(ctx, keyShare); err != nil { + return nil, ErrShareSaveFailed + } + logger.Info("Share saved to database (persistent party)", + zap.String("party_id", input.PartyID), + zap.String("session_id", input.SessionID.String())) + + case "delegate": + // Delegate Party: do NOT save to database, return to user + shareForUser = encryptedShare + logger.Info("Share NOT saved, will be returned to user (delegate party)", + zap.String("party_id", input.PartyID), + zap.String("session_id", input.SessionID.String()), + zap.Int("share_size", len(shareForUser))) + + case "temporary": + // Temporary Party: optionally save to temp storage (not implemented yet) + logger.Info("Temporary party - share not saved", + zap.String("party_id", input.PartyID)) + + default: + // Default to persistent for safety + if err := uc.keyShareRepo.Save(ctx, keyShare); err != nil { + return nil, ErrShareSaveFailed + } + logger.Warn("Unknown party role, defaulting to persistent", + zap.String("party_id", input.PartyID), + zap.String("role", partyRole)) } - // 6. Report completion to coordinator + // 7. Report completion to coordinator if err := uc.sessionClient.ReportCompletion(ctx, input.SessionID, input.PartyID, publicKey); err != nil { logger.Error("failed to report completion", zap.Error(err)) - // Don't fail - share is saved + // Don't fail - share is handled } return &ParticipateKeygenOutput{ - Success: true, - KeyShare: keyShare, - PublicKey: publicKey, + Success: true, + KeyShare: keyShare, + PublicKey: publicKey, + ShareForUser: shareForUser, // Only populated for delegate parties }, nil } @@ -292,3 +328,13 @@ func (h *keygenMessageHandler) convertMessages(ctx context.Context, inChan <-cha } } } + +// getPartyRole gets the party role from environment variable +// Returns "persistent" (default), "delegate", or "temporary" +func (uc *ParticipateKeygenUseCase) getPartyRole() string { + role := os.Getenv("PARTY_ROLE") + if role == "" { + return "persistent" // Default to persistent for safety + } + return role +} diff --git a/backend/mpc-system/services/server-party/cmd/server/main.go b/backend/mpc-system/services/server-party/cmd/server/main.go index 0880a057..bceabf66 100644 --- a/backend/mpc-system/services/server-party/cmd/server/main.go +++ b/backend/mpc-system/services/server-party/cmd/server/main.go @@ -22,9 +22,13 @@ import ( grpcclient "github.com/rwadurian/mpc-system/services/server-party/adapters/output/grpc" "github.com/rwadurian/mpc-system/services/server-party/adapters/output/postgres" "github.com/rwadurian/mpc-system/services/server-party/application/use_cases" + "github.com/rwadurian/mpc-system/services/server-party/infrastructure/cache" "go.uber.org/zap" ) +// Global share cache for delegate parties +var globalShareCache *cache.ShareCache + func main() { // Parse flags configPath := flag.String("config", "", "Path to config file") @@ -51,6 +55,10 @@ func main() { zap.String("environment", cfg.Server.Environment), zap.Int("http_port", cfg.Server.HTTPPort)) + // Initialize share cache for delegate parties (15 minute TTL) + globalShareCache = cache.NewShareCache(15 * time.Minute) + logger.Info("Share cache initialized", zap.Duration("ttl", 15*time.Minute)) + // Initialize database connection db, err := initDatabase(cfg.Database) if err != nil { @@ -316,6 +324,20 @@ func startHTTPServer( zap.String("session_id", req.SessionID), zap.String("party_id", req.PartyID), zap.Bool("success", output.Success)) + + // If this is a delegate party and share is available, store in cache + if output.ShareForUser != nil && len(output.ShareForUser) > 0 { + globalShareCache.Store( + sessionID, + req.PartyID, + output.ShareForUser, + output.PublicKey, + ) + logger.Info("Share stored in cache for user retrieval (delegate party)", + zap.String("session_id", req.SessionID), + zap.String("party_id", req.PartyID), + zap.Int("share_size", len(output.ShareForUser))) + } }() c.JSON(http.StatusAccepted, gin.H{ @@ -422,6 +444,52 @@ func startHTTPServer( "shares": shareInfos, }) }) + + // Get user share for delegate parties (one-time retrieval) + // This endpoint is ONLY for delegate parties to return shares to users + api.GET("/sessions/:session_id/user-share", func(c *gin.Context) { + sessionIDStr := c.Param("session_id") + + // Check if this is a delegate party + partyRole := os.Getenv("PARTY_ROLE") + if partyRole != "delegate" { + c.JSON(http.StatusForbidden, gin.H{ + "error": "This endpoint is only available for delegate parties", + "role": partyRole, + }) + return + } + + sessionID, err := uuid.Parse(sessionIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid session_id format", + }) + return + } + + // Retrieve and delete share from cache (one-time retrieval) + entry, exists := globalShareCache.GetAndDelete(sessionID) + if !exists { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Share not found or already retrieved", + "note": "Shares can only be retrieved once and expire after 15 minutes", + }) + return + } + + logger.Info("User share retrieved successfully", + zap.String("session_id", sessionIDStr), + zap.String("party_id", entry.PartyID)) + + c.JSON(http.StatusOK, gin.H{ + "session_id": sessionIDStr, + "party_id": entry.PartyID, + "share": hex.EncodeToString(entry.Share), + "public_key": hex.EncodeToString(entry.PublicKey), + "note": "This share has been deleted from memory and cannot be retrieved again", + }) + }) } logger.Info("Starting HTTP server", zap.Int("port", cfg.Server.HTTPPort)) diff --git a/backend/mpc-system/services/server-party/infrastructure/cache/share_cache.go b/backend/mpc-system/services/server-party/infrastructure/cache/share_cache.go new file mode 100644 index 00000000..6ce77fb5 --- /dev/null +++ b/backend/mpc-system/services/server-party/infrastructure/cache/share_cache.go @@ -0,0 +1,170 @@ +package cache + +import ( + "sync" + "time" + + "github.com/google/uuid" + "github.com/rwadurian/mpc-system/pkg/logger" + "go.uber.org/zap" +) + +// ShareCacheEntry represents a cached share entry +type ShareCacheEntry struct { + SessionID uuid.UUID + PartyID string + Share []byte + PublicKey []byte + ExpiresAt time.Time + RetrievedOnce bool // Track if share has been retrieved +} + +// ShareCache provides in-memory caching for delegate party shares +// Shares are stored temporarily and deleted after retrieval +type ShareCache struct { + entries map[string]*ShareCacheEntry // sessionID -> entry + mu sync.RWMutex + ttl time.Duration +} + +// NewShareCache creates a new share cache +func NewShareCache(ttl time.Duration) *ShareCache { + cache := &ShareCache{ + entries: make(map[string]*ShareCacheEntry), + ttl: ttl, + } + + // Start background cleanup goroutine + go cache.cleanupExpired() + + return cache +} + +// Store stores a share in the cache +func (c *ShareCache) Store(sessionID uuid.UUID, partyID string, share, publicKey []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + entry := &ShareCacheEntry{ + SessionID: sessionID, + PartyID: partyID, + Share: share, + PublicKey: publicKey, + ExpiresAt: time.Now().Add(c.ttl), + RetrievedOnce: false, + } + + c.entries[sessionID.String()] = entry + + logger.Info("Share stored in cache", + zap.String("session_id", sessionID.String()), + zap.String("party_id", partyID), + zap.Int("share_size", len(share)), + zap.Time("expires_at", entry.ExpiresAt)) +} + +// Get retrieves a share from the cache +// The share is marked as retrieved but not deleted yet +// Use Delete() to remove it after successful delivery to user +func (c *ShareCache) Get(sessionID uuid.UUID) (*ShareCacheEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, exists := c.entries[sessionID.String()] + if !exists { + return nil, false + } + + // Check if expired + if time.Now().After(entry.ExpiresAt) { + delete(c.entries, sessionID.String()) + logger.Warn("Share expired", + zap.String("session_id", sessionID.String())) + return nil, false + } + + // Mark as retrieved + entry.RetrievedOnce = true + + logger.Info("Share retrieved from cache", + zap.String("session_id", sessionID.String()), + zap.String("party_id", entry.PartyID)) + + return entry, true +} + +// Delete deletes a share from the cache +func (c *ShareCache) Delete(sessionID uuid.UUID) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.entries, sessionID.String()) + + logger.Info("Share deleted from cache", + zap.String("session_id", sessionID.String())) +} + +// GetAndDelete retrieves and immediately deletes a share (atomic operation) +// This ensures the share can only be retrieved once +func (c *ShareCache) GetAndDelete(sessionID uuid.UUID) (*ShareCacheEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, exists := c.entries[sessionID.String()] + if !exists { + return nil, false + } + + // Check if expired + if time.Now().After(entry.ExpiresAt) { + delete(c.entries, sessionID.String()) + logger.Warn("Share expired", + zap.String("session_id", sessionID.String())) + return nil, false + } + + // Delete immediately + delete(c.entries, sessionID.String()) + + logger.Info("Share retrieved and deleted from cache (one-time retrieval)", + zap.String("session_id", sessionID.String()), + zap.String("party_id", entry.PartyID)) + + return entry, true +} + +// Size returns the number of entries in the cache +func (c *ShareCache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.entries) +} + +// cleanupExpired removes expired entries periodically +func (c *ShareCache) cleanupExpired() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + c.mu.Lock() + now := time.Now() + var expired []string + + for sessionID, entry := range c.entries { + if now.After(entry.ExpiresAt) { + expired = append(expired, sessionID) + } + } + + for _, sessionID := range expired { + delete(c.entries, sessionID) + } + + if len(expired) > 0 { + logger.Info("Cleaned up expired shares", + zap.Int("count", len(expired))) + } + + c.mu.Unlock() + } +}