feat(mpc-system): implement delegate party for hybrid custody
- Add ShareForUser field to ParticipateKeygenOutput
- Implement role-based share handling (persistent/delegate/temporary)
- Add in-memory share cache with 15-minute TTL for delegate parties
- Add GET /api/v1/sessions/:session_id/user-share endpoint for one-time share retrieval
- Shares from delegate parties are NOT saved to database
- Add comprehensive Delegate Party implementation guide
This implements hybrid custody model similar to Fireblocks and ZenGo:
- Persistent parties: shares stored in server database
- Delegate parties: shares returned to user, deleted from memory after retrieval
🤖 Generated with Claude Code
This commit is contained in:
parent
c976fd3eb1
commit
d7f181f2ec
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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 <optional_jwt_token>"
|
||||
```
|
||||
|
||||
**成功响应**:
|
||||
|
||||
```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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
170
backend/mpc-system/services/server-party/infrastructure/cache/share_cache.go
vendored
Normal file
170
backend/mpc-system/services/server-party/infrastructure/cache/share_cache.go
vendored
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue