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:
hailin 2025-12-05 09:07:46 -08:00
parent c976fd3eb1
commit d7f181f2ec
5 changed files with 823 additions and 49 deletions

View File

@ -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": []

View File

@ -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-31 个用户) |
| **ZenGo** | 1 | 1 | 2-of-21 个用户) |
| **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)

View File

@ -3,6 +3,7 @@ package use_cases
import (
"context"
"errors"
"os"
"time"
"github.com/google/uuid"
@ -33,6 +34,7 @@ type ParticipateKeygenOutput struct {
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,
)
// 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()))
// 6. Report completion to coordinator
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))
}
// 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,
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
}

View File

@ -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))

View 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()
}
}