feat(service-party-app): implement co-sign multi-party signing

Add complete co-sign functionality for multi-party transaction signing:

Frontend (React):
- CoSignCreate.tsx: Create signing session with share selection
- CoSignJoin.tsx: Join signing session via invite code
- CoSignSession.tsx: Monitor signing progress and results
- Add routes in App.tsx for new pages

Backend (Electron):
- main.ts: Add IPC handlers for co-sign operations
- tss-handler.ts: Add participateSign() for TSS signing
- preload.ts: Expose cosign API to renderer
- account-client.ts: Add sign session API types

TSS Party (Go):
- main.go: Implement 'sign' command for GG20 signing protocol
- integration_test.go: Add comprehensive tests for signing flow

Infrastructure:
- docker-compose.windows.yml: Expose gRPC port 50051

This is a pure additive change that does not affect existing
persistent role keygen/sign functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-30 18:36:11 -08:00
parent 7696f663a5
commit ebea74e57b
14 changed files with 2995 additions and 20 deletions

View File

@ -159,6 +159,7 @@ services:
dockerfile: services/message-router/Dockerfile dockerfile: services/message-router/Dockerfile
container_name: mpc-message-router container_name: mpc-message-router
ports: ports:
- "50051:50051" # gRPC for party connections
- "8082:8080" - "8082:8080"
environment: environment:
MPC_SERVER_GRPC_PORT: 50051 MPC_SERVER_GRPC_PORT: 50051

View File

@ -77,6 +77,26 @@ let activeKeygenSession: ActiveKeygenSession | null = null;
// Keygen 幂等性保护:追踪正在进行的 keygen 会话 ID // Keygen 幂等性保护:追踪正在进行的 keygen 会话 ID
let keygenInProgressSessionId: string | null = null; let keygenInProgressSessionId: string | null = null;
// ===========================================================================
// Co-Sign 相关状态
// ===========================================================================
// 当前正在进行的 Co-Sign 会话信息
interface ActiveCoSignSession {
sessionId: string;
partyIndex: number;
participants: Array<{ partyId: string; partyIndex: number; name: string }>;
threshold: { t: number; n: number };
walletName: string;
messageHash: string;
shareId: string;
sharePassword: string;
}
let activeCoSignSession: ActiveCoSignSession | null = null;
// Co-Sign 幂等性保护:追踪正在进行的签名会话 ID
let signInProgressSessionId: string | null = null;
// 会话事件缓存 - 解决前端订阅时可能错过事件的时序问题 // 会话事件缓存 - 解决前端订阅时可能错过事件的时序问题
// 当事件到达时,前端可能还在页面导航中,尚未订阅 // 当事件到达时,前端可能还在页面导航中,尚未订阅
interface SessionEventData { interface SessionEventData {
@ -619,6 +639,295 @@ async function handleKeygenComplete(result: KeygenResult) {
} }
} }
// ===========================================================================
// Co-Sign 相关处理函数
// ===========================================================================
// 检查并触发 Co-Sign在收到 all_joined 事件后调用)
async function checkAndTriggerCoSign(sessionId: string) {
console.log('[CO-SIGN] checkAndTriggerCoSign called with sessionId:', sessionId);
if (!activeCoSignSession || activeCoSignSession.sessionId !== sessionId) {
console.log('[CO-SIGN] No matching active co-sign session for', sessionId);
return;
}
console.log('[CO-SIGN] Active session found:', {
sessionId: activeCoSignSession.sessionId,
partyIndex: activeCoSignSession.partyIndex,
threshold: activeCoSignSession.threshold,
participantCount: activeCoSignSession.participants.length,
});
// 如果 TSS 已经在运行,不重复触发
if (tssHandler?.getIsRunning()) {
console.log('[CO-SIGN] TSS already running, skip check');
return;
}
const pollIntervalMs = 2000; // 2秒轮询间隔
const maxWaitMs = 5 * 60 * 1000; // 5分钟超时
const startTime = Date.now();
console.log('[CO-SIGN] Starting to poll session status...');
debugLog.info('main', `Starting to poll co-sign session status for ${sessionId}`);
while (Date.now() - startTime < maxWaitMs) {
// 检查会话是否仍然有效
if (!activeCoSignSession || activeCoSignSession.sessionId !== sessionId) {
debugLog.warn('main', 'Active co-sign session changed, stopping poll');
return;
}
// 如果 TSS 已经在运行,停止轮询
if (tssHandler?.getIsRunning()) {
debugLog.info('main', 'TSS started running, stopping poll');
return;
}
try {
// 获取签名会话状态
const status = await accountClient?.getSignSessionStatus(sessionId);
if (!status) {
debugLog.warn('main', 'Failed to get sign session status, will retry');
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
continue;
}
const expectedT = activeCoSignSession.threshold.t;
const currentParticipants = status.joined_count || 0;
debugLog.debug('main', `Sign session ${sessionId} status: ${status.status}, participants: ${currentParticipants}/${expectedT}`);
// 检查是否满足启动条件
const hasAllParticipants = currentParticipants >= expectedT;
const statusReady = status.status === 'in_progress' ||
status.status === 'all_joined' ||
status.status === 'waiting_for_sign';
console.log('[CO-SIGN] Check conditions:', {
hasAllParticipants,
statusReady,
currentParticipants,
expectedT,
status: status.status,
});
if (hasAllParticipants && statusReady) {
console.log('[CO-SIGN] Conditions met! Triggering sign...');
debugLog.info('main', `All ${expectedT} participants joined (status: ${status.status}), triggering sign...`);
// 更新参与者列表
if (status.parties && status.parties.length > 0) {
const myPartyId = grpcClient?.getPartyId();
const updatedParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
for (const p of status.parties) {
const existing = activeCoSignSession.participants.find(ep => ep.partyId === p.party_id);
updatedParticipants.push({
partyId: p.party_id,
partyIndex: p.party_index,
name: existing?.name || (p.party_id === myPartyId ? '我' : `参与方 ${p.party_index + 1}`),
});
}
activeCoSignSession.participants = updatedParticipants;
const myInfo = updatedParticipants.find(p => p.partyId === myPartyId);
if (myInfo) {
activeCoSignSession.partyIndex = myInfo.partyIndex;
}
}
const selectedParties = activeCoSignSession.participants.map(p => p.partyId);
await handleCoSignStart({
eventType: 'session_started',
sessionId: sessionId,
thresholdT: activeCoSignSession.threshold.t,
thresholdN: activeCoSignSession.threshold.n,
selectedParties: selectedParties,
});
return;
}
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
} catch (error) {
debugLog.error('main', `Failed to check sign session status: ${error}, will retry`);
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
}
}
// 超时
debugLog.error('main', `Timeout: failed to start sign within 5 minutes for session ${sessionId}`);
if (mainWindow) {
mainWindow.webContents.send(`cosign:events:${sessionId}`, {
type: 'sign_start_timeout',
error: '启动签名超时,请重试',
});
}
}
// 处理 Co-Sign 会话开始事件 - 触发签名
async function handleCoSignStart(event: {
eventType: string;
sessionId: string;
thresholdT: number;
thresholdN: number;
selectedParties: string[];
}) {
console.log('[CO-SIGN] handleCoSignStart called:', {
eventType: event.eventType,
sessionId: event.sessionId,
thresholdT: event.thresholdT,
thresholdN: event.thresholdN,
selectedParties: event.selectedParties?.length,
});
if (!activeCoSignSession) {
console.log('[CO-SIGN] No active co-sign session, ignoring');
debugLog.debug('main', 'No active co-sign session, ignoring sign start event');
return;
}
if (activeCoSignSession.sessionId !== event.sessionId) {
debugLog.debug('main', `Session ID mismatch: expected ${activeCoSignSession.sessionId}, got ${event.sessionId}`);
return;
}
// 幂等性保护
if (signInProgressSessionId === event.sessionId) {
debugLog.debug('main', `Sign already in progress for session ${event.sessionId}, skipping duplicate trigger`);
return;
}
if (tssHandler?.getIsRunning()) {
debugLog.debug('main', 'TSS already running, skipping');
return;
}
if (!tssHandler || !('participateSign' in tssHandler)) {
debugLog.error('tss', 'TSS handler not initialized or does not support signing');
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
type: 'failed',
error: 'TSS handler not initialized',
});
return;
}
// 标记签名开始
signInProgressSessionId = event.sessionId;
// 获取 share 数据
const share = database?.getShare(activeCoSignSession.shareId, activeCoSignSession.sharePassword);
if (!share) {
debugLog.error('main', 'Failed to get share data');
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
type: 'failed',
error: 'Failed to get share data',
});
signInProgressSessionId = null;
return;
}
console.log('[CO-SIGN] Calling tssHandler.participateSign with:', {
sessionId: activeCoSignSession.sessionId,
partyId: grpcClient?.getPartyId(),
partyIndex: activeCoSignSession.partyIndex,
participants: activeCoSignSession.participants.map(p => ({ partyId: p.partyId.substring(0, 8), partyIndex: p.partyIndex })),
threshold: activeCoSignSession.threshold,
messageHash: activeCoSignSession.messageHash.substring(0, 16) + '...',
});
debugLog.info('tss', `Starting sign for session ${event.sessionId}...`);
try {
const result = await (tssHandler as TSSHandler).participateSign(
activeCoSignSession.sessionId,
grpcClient?.getPartyId() || '',
activeCoSignSession.partyIndex,
activeCoSignSession.participants,
activeCoSignSession.threshold,
activeCoSignSession.messageHash,
share.raw_share,
activeCoSignSession.sharePassword
);
if (result.success) {
debugLog.info('tss', 'Sign completed successfully');
await handleCoSignComplete(result);
} else {
debugLog.error('tss', `Sign failed: ${result.error}`);
mainWindow?.webContents.send(`cosign:events:${activeCoSignSession.sessionId}`, {
type: 'failed',
error: result.error || 'Sign failed',
});
signInProgressSessionId = null;
}
} catch (error) {
debugLog.error('tss', `Sign error: ${(error as Error).message}`);
mainWindow?.webContents.send(`cosign:events:${activeCoSignSession?.sessionId}`, {
type: 'failed',
error: (error as Error).message,
});
signInProgressSessionId = null;
}
}
// 处理 Co-Sign 完成 - 保存签名并报告完成
async function handleCoSignComplete(result: { success: boolean; signature: Buffer; error?: string }) {
if (!activeCoSignSession || !database || !grpcClient) {
debugLog.error('main', 'Missing required components for sign completion');
return;
}
const sessionId = activeCoSignSession.sessionId;
const partyId = grpcClient.getPartyId();
try {
const signatureHex = result.signature.toString('hex');
// 1. 更新签名历史
database.updateSigningHistory(sessionId, {
status: 'completed',
signature: signatureHex,
});
debugLog.info('main', `Signature saved: ${signatureHex.substring(0, 32)}...`);
// 2. 报告完成给 session-coordinator
const allCompleted = await grpcClient.reportCompletion(
sessionId,
partyId || '',
result.signature
);
debugLog.info('grpc', `Reported sign completion to session-coordinator, all_completed: ${allCompleted}`);
// 3. 通知前端
mainWindow?.webContents.send(`cosign:events:${sessionId}`, {
type: 'completed',
signature: signatureHex,
allCompleted: allCompleted,
});
// 4. 清理活跃会话和幂等性标志
activeCoSignSession = null;
signInProgressSessionId = null;
debugLog.info('main', 'Co-Sign session completed and cleaned up');
} catch (error) {
debugLog.error('main', `Failed to handle sign completion: ${error}`);
mainWindow?.webContents.send(`cosign:events:${sessionId}`, {
type: 'failed',
error: (error as Error).message,
});
signInProgressSessionId = null;
}
}
// 连接并注册到 Message Router // 连接并注册到 Message Router
async function connectAndRegisterToMessageRouter() { async function connectAndRegisterToMessageRouter() {
if (!grpcClient || !database) { if (!grpcClient || !database) {
@ -688,19 +997,40 @@ async function connectAndRegisterToMessageRouter() {
// 转发事件到前端 // 转发事件到前端
mainWindow?.webContents.send(`session:events:${event.session_id}`, eventData); mainWindow?.webContents.send(`session:events:${event.session_id}`, eventData);
// 根据事件类型处理 // 根据事件类型处理 - 区分 Keygen 和 Co-Sign 会话
const isCoSignSession = activeCoSignSession?.sessionId === event.session_id;
const isKeygenSession = activeKeygenSession?.sessionId === event.session_id;
if (event.event_type === 'all_joined') { if (event.event_type === 'all_joined') {
// 收到 all_joined 事件表示所有参与方都已加入 // 收到 all_joined 事件表示所有参与方都已加入
// 此时启动 5 分钟倒计时,在此期间完成 keygen 启动 debugLog.info('main', `Received all_joined event for session ${event.session_id}, isCoSign=${isCoSignSession}, isKeygen=${isKeygenSession}`);
debugLog.info('main', `Received all_joined event for session ${event.session_id}, starting 5-minute keygen countdown`);
// 使用 setImmediate 确保不阻塞事件处理 if (isCoSignSession) {
// Co-Sign 会话:转发到 cosign 频道并触发签名
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
setImmediate(() => {
checkAndTriggerCoSign(event.session_id);
});
} else if (isKeygenSession) {
// Keygen 会话:启动 5 分钟倒计时
setImmediate(() => { setImmediate(() => {
checkAndTriggerKeygen(event.session_id); checkAndTriggerKeygen(event.session_id);
}); });
}
} else if (event.event_type === 'session_started') { } else if (event.event_type === 'session_started') {
// session_started 事件表示 keygen 可以开始了(所有人已准备就绪) // session_started 事件表示可以开始了
// 直接触发 keygen if (isCoSignSession) {
// Co-Sign 会话
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
await handleCoSignStart({
eventType: event.event_type,
sessionId: event.session_id,
thresholdN: event.threshold_n,
thresholdT: event.threshold_t,
selectedParties: event.selected_parties,
});
} else if (isKeygenSession) {
// Keygen 会话
await handleSessionStart({ await handleSessionStart({
eventType: event.event_type, eventType: event.event_type,
sessionId: event.session_id, sessionId: event.session_id,
@ -709,6 +1039,12 @@ async function connectAndRegisterToMessageRouter() {
selectedParties: event.selected_parties, selectedParties: event.selected_parties,
}); });
} }
} else if (event.event_type === 'participant_joined') {
// 参与者加入事件 - 也需要区分会话类型
if (isCoSignSession) {
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
}
}
}); });
} catch (error) { } catch (error) {
console.error('Failed to connect/register to Message Router:', (error as Error).message); console.error('Failed to connect/register to Message Router:', (error as Error).message);
@ -1206,6 +1542,317 @@ function setupIpcHandlers() {
return accountClient?.getBaseUrl() || 'https://rwaapi.szaiai.com'; return accountClient?.getBaseUrl() || 'https://rwaapi.szaiai.com';
}); });
// ===========================================================================
// Co-Sign 相关 IPC 处理器
// ===========================================================================
// 创建 Co-Sign 会话
ipcMain.handle('cosign:createSession', async (_event, params: {
shareId: string;
sharePassword: string;
messageHash: string;
initiatorName?: string;
}) => {
try {
// 获取当前 party ID
const partyId = grpcClient?.getPartyId();
if (!partyId) {
return { success: false, error: '请先连接到消息路由器' };
}
// 从本地获取 share 信息
const share = database?.getShare(params.shareId, params.sharePassword);
if (!share) {
return { success: false, error: 'Share 不存在或密码错误' };
}
// 解析参与者信息
const participants = JSON.parse(share.participants_json || '[]');
const parties = participants.map((p: { partyId: string }, index: number) => ({
party_id: p.partyId,
party_index: index,
}));
// 创建签名会话
const result = await accountClient?.createSignSession({
keygen_session_id: share.session_id,
wallet_name: share.wallet_name,
message_hash: params.messageHash,
parties: parties,
threshold_t: share.threshold_t,
initiator_name: params.initiatorName || '发起者',
});
if (!result?.session_id) {
return { success: false, error: '创建签名会话失败: 未返回会话ID' };
}
// 创建签名历史记录
database?.createSigningHistory({
shareId: params.shareId,
sessionId: result.session_id,
messageHash: params.messageHash,
});
// 发起方自动加入会话
const joinToken = result.join_token;
if (joinToken) {
console.log('[CO-SIGN] Initiator auto-joining session...');
const joinResult = await grpcClient?.joinSession(result.session_id, partyId, joinToken);
if (joinResult?.success) {
// 设置活跃的 Co-Sign 会话信息
const signParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
// 添加发起方
signParticipants.push({
partyId: partyId,
partyIndex: joinResult.party_index,
name: params.initiatorName || '发起者',
});
activeCoSignSession = {
sessionId: result.session_id,
partyIndex: joinResult.party_index,
participants: signParticipants,
threshold: {
t: share.threshold_t,
n: share.threshold_n,
},
walletName: share.wallet_name,
messageHash: params.messageHash,
shareId: params.shareId,
sharePassword: params.sharePassword,
};
console.log('[CO-SIGN] Initiator active session set:', {
sessionId: activeCoSignSession.sessionId,
partyIndex: activeCoSignSession.partyIndex,
threshold: activeCoSignSession.threshold,
});
// 预订阅消息流
if (tssHandler && 'prepareForSign' in tssHandler) {
debugLog.info('tss', `Initiator preparing for sign: subscribing to messages for session ${result.session_id}`);
(tssHandler as TSSHandler).prepareForSign(result.session_id, partyId);
}
// 检查会话状态
const sessionStatus = joinResult.session_info?.status;
debugLog.info('main', `Initiator JoinSession response: status=${sessionStatus}`);
if (sessionStatus === 'in_progress') {
debugLog.info('main', 'Session already in_progress, triggering sign immediately');
setImmediate(async () => {
const selectedParties = activeCoSignSession?.participants.map(p => p.partyId) || [];
await handleCoSignStart({
eventType: 'session_started',
sessionId: result.session_id,
thresholdT: share.threshold_t,
thresholdN: share.threshold_n,
selectedParties: selectedParties,
});
});
}
} else {
console.warn('[CO-SIGN] Initiator failed to join session');
}
}
return {
success: true,
sessionId: result.session_id,
inviteCode: result.invite_code,
walletName: share.wallet_name,
expiresAt: result.expires_at,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 验证 Co-Sign 邀请码
ipcMain.handle('cosign:validateInviteCode', async (_event, { code }) => {
try {
debugLog.info('account', `Validating co-sign invite code: ${code}`);
const result = await accountClient?.getSignSessionByInviteCode(code);
if (!result?.session_id) {
return { success: false, error: '无效的邀请码' };
}
return {
success: true,
sessionInfo: {
sessionId: result.session_id,
keygenSessionId: result.keygen_session_id,
walletName: result.wallet_name,
messageHash: result.message_hash,
threshold: {
t: result.threshold_t,
n: result.parties?.length || 0,
},
status: result.status,
currentParticipants: result.joined_count || 0,
parties: result.parties,
},
joinToken: result.join_token,
};
} catch (error) {
debugLog.error('account', `Failed to validate co-sign invite code: ${(error as Error).message}`);
return { success: false, error: (error as Error).message };
}
});
// 加入 Co-Sign 会话
ipcMain.handle('cosign:joinSession', async (_event, params: {
sessionId: string;
shareId: string;
sharePassword: string;
joinToken: string;
walletName?: string;
messageHash: string;
threshold: { t: number; n: number };
}) => {
try {
const partyId = grpcClient?.getPartyId();
if (!partyId) {
return { success: false, error: '请先连接到消息路由器' };
}
// 验证 share
const share = database?.getShare(params.shareId, params.sharePassword);
if (!share) {
return { success: false, error: 'Share 不存在或密码错误' };
}
debugLog.info('grpc', `Joining co-sign session: sessionId=${params.sessionId}, partyId=${partyId}`);
const result = await grpcClient?.joinSession(params.sessionId, partyId, params.joinToken);
if (result?.success) {
// 设置活跃的 Co-Sign 会话
const participants: Array<{ partyId: string; partyIndex: number; name: string }> = result.other_parties?.map((p: { party_id: string; party_index: number }, idx: number) => ({
partyId: p.party_id,
partyIndex: p.party_index,
name: `参与方 ${idx + 1}`,
})) || [];
// 添加自己
participants.push({
partyId: partyId,
partyIndex: result.party_index,
name: '我',
});
// 按 partyIndex 排序
participants.sort((a, b) => a.partyIndex - b.partyIndex);
activeCoSignSession = {
sessionId: params.sessionId,
partyIndex: result.party_index,
participants: participants,
threshold: params.threshold,
walletName: params.walletName || share.wallet_name,
messageHash: params.messageHash,
shareId: params.shareId,
sharePassword: params.sharePassword,
};
console.log('[CO-SIGN] Active session set:', {
sessionId: activeCoSignSession.sessionId,
partyIndex: activeCoSignSession.partyIndex,
participantCount: activeCoSignSession.participants.length,
threshold: activeCoSignSession.threshold,
});
// 创建签名历史记录
database?.createSigningHistory({
shareId: params.shareId,
sessionId: params.sessionId,
messageHash: params.messageHash,
});
// 预订阅消息流
if (tssHandler && 'prepareForSign' in tssHandler) {
debugLog.info('tss', `Preparing for sign: subscribing to messages for session ${params.sessionId}`);
(tssHandler as TSSHandler).prepareForSign(params.sessionId, partyId);
}
// 检查会话状态
const sessionStatus = result.session_info?.status;
debugLog.info('main', `JoinSession response: status=${sessionStatus}`);
if (sessionStatus === 'in_progress') {
debugLog.info('main', 'Session already in_progress, triggering sign immediately');
setImmediate(async () => {
const selectedParties = activeCoSignSession?.participants.map(p => p.partyId) || [];
await handleCoSignStart({
eventType: 'session_started',
sessionId: params.sessionId,
thresholdT: params.threshold.t,
thresholdN: params.threshold.n,
selectedParties: selectedParties,
});
});
}
return { success: true, data: result };
} else {
return { success: false, error: '加入会话失败' };
}
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 获取 Co-Sign 会话状态
ipcMain.handle('cosign:getSessionStatus', async (_event, { sessionId }) => {
try {
const result = await accountClient?.getSignSessionStatus(sessionId);
return {
success: true,
session: {
sessionId: result?.session_id,
status: result?.status,
joinedCount: result?.joined_count,
threshold: {
t: activeCoSignSession?.threshold?.t || 0,
n: activeCoSignSession?.threshold?.n || 0,
},
participants: result?.parties?.map((p: { party_id: string; party_index: number }, idx: number) => ({
partyId: p.party_id,
partyIndex: p.party_index,
name: activeCoSignSession?.participants?.find(ap => ap.partyId === p.party_id)?.name || `参与方 ${idx + 1}`,
status: 'ready',
})) || [],
messageHash: activeCoSignSession?.messageHash || '',
walletName: activeCoSignSession?.walletName || '',
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 订阅 Co-Sign 会话事件
ipcMain.on('cosign:subscribeSessionEvents', (_event, { sessionId }) => {
debugLog.debug('main', `Frontend subscribing to co-sign session events: ${sessionId}`);
// 获取并发送缓存的事件
const cachedEvents = getAndClearCachedEvents(sessionId);
if (cachedEvents.length > 0) {
debugLog.info('main', `Sending ${cachedEvents.length} cached events to frontend for co-sign session ${sessionId}`);
for (const event of cachedEvents) {
mainWindow?.webContents.send(`cosign:events:${sessionId}`, event);
}
}
});
// 取消订阅 Co-Sign 会话事件
ipcMain.on('cosign:unsubscribeSessionEvents', (_event, { sessionId }) => {
debugLog.debug('main', `Frontend unsubscribing from co-sign session events: ${sessionId}`);
});
// =========================================================================== // ===========================================================================
// Share 存储相关 (SQLite) // Share 存储相关 (SQLite)
// =========================================================================== // ===========================================================================

View File

@ -137,6 +137,17 @@ export interface GetSignSessionByInviteCodeResponse {
expires_at: number; expires_at: number;
parties: SignPartyInfo[]; parties: SignPartyInfo[];
joined_count: number; joined_count: number;
join_token?: string;
}
export interface GetSignSessionStatusResponse {
session_id: string;
status: string;
threshold_t: number;
joined_count: number;
parties: SignPartyInfo[];
message_hash?: string;
signature?: string;
} }
// 错误响应 // 错误响应
@ -313,6 +324,16 @@ export class AccountClient {
); );
} }
/**
* Sign
*/
async getSignSessionStatus(sessionId: string): Promise<GetSignSessionStatusResponse> {
return this.request<GetSignSessionStatusResponse>(
'GET',
`/api/v1/co-managed/sign/${sessionId}`
);
}
// =========================================================================== // ===========================================================================
// 健康检查 // 健康检查
// =========================================================================== // ===========================================================================

View File

@ -477,6 +477,239 @@ export class TSSHandler extends EventEmitter {
this.messageBuffer = []; this.messageBuffer = [];
} }
// ===========================================================================
// Co-Sign 相关方法 - 与 Keygen 完全隔离的签名功能
// ===========================================================================
/**
* - joinSession
* prepareForKeygen
*
* @param sessionId ID
* @param partyId party ID
*/
prepareForSign(sessionId: string, partyId: string): void {
// 如果已经为同一个 session 准备过,跳过
if (this.isPrepared && this.sessionId === sessionId) {
console.log('[TSS-SIGN] Already prepared for same session, skip');
return;
}
// 如果为不同的 session 准备过,先取消旧的订阅
if (this.isPrepared && this.sessionId !== sessionId) {
console.log(`[TSS-SIGN] Switching from session ${this.sessionId?.substring(0, 8)}... to ${sessionId.substring(0, 8)}...`);
this.grpcClient.removeAllListeners('mpcMessage');
this.grpcClient.unsubscribeMessages();
this.messageBuffer = [];
}
console.log(`[TSS-SIGN] Preparing for sign: session=${sessionId.substring(0, 8)}..., party=${partyId.substring(0, 8)}...`);
this.sessionId = sessionId;
this.partyId = partyId;
this.isPrepared = true;
this.messageBuffer = [];
// 立即订阅消息流,开始缓冲消息
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
this.grpcClient.subscribeMessages(sessionId, partyId);
console.log('[TSS-SIGN] Message subscription started, buffering enabled');
}
/**
*
*/
cancelSignPrepare(): void {
if (!this.isPrepared) {
return;
}
console.log('[TSS-SIGN] Canceling sign prepare');
this.isPrepared = false;
this.messageBuffer = [];
this.grpcClient.removeAllListeners('mpcMessage');
this.grpcClient.unsubscribeMessages();
this.sessionId = null;
this.partyId = null;
}
/**
* Co-Sign
*
* @param sessionId ID
* @param partyId party ID
* @param partyIndex
* @param participants (T )
* @param threshold { t: 签名阈值, n: keygen时的总参与方数 }
* @param messageHash (hex )
* @param shareData share (base64 )
* @param sharePassword share
*/
async participateSign(
sessionId: string,
partyId: string,
partyIndex: number,
participants: ParticipantInfo[],
threshold: { t: number; n: number },
messageHash: string,
shareData: string,
sharePassword: string
): Promise<SignResult> {
if (this.isRunning) {
throw new Error('TSS protocol already running');
}
// 检查是否已经预订阅
const wasPrepared = this.isPrepared && this.sessionId === sessionId;
const bufferedCount = this.messageBuffer.length;
console.log(`[TSS-SIGN] Starting sign: wasPrepared=${wasPrepared}, bufferedMessages=${bufferedCount}`);
this.sessionId = sessionId;
this.partyId = partyId;
this.partyIndex = partyIndex;
this.participants = participants;
this.isRunning = true;
this.isProcessReady = false;
// 注意:不清空消息缓冲,保留预订阅阶段收到的消息
// 构建 party index map
this.partyIndexMap.clear();
for (const p of participants) {
this.partyIndexMap.set(p.partyId, p.partyIndex);
}
return new Promise((resolve, reject) => {
try {
const binaryPath = this.getTSSBinaryPath();
console.log(`[TSS-SIGN] Binary path: ${binaryPath}`);
console.log(`[TSS-SIGN] Binary exists: ${fs.existsSync(binaryPath)}`);
// 构建参与者列表 JSON
const participantsJson = JSON.stringify(participants);
console.log(`[TSS-SIGN] Participants: ${participantsJson}`);
console.log(`[TSS-SIGN] partyIndex=${partyIndex}, threshold=${threshold.t}-of-${threshold.n}`);
console.log(`[TSS-SIGN] messageHash=${messageHash.substring(0, 16)}...`);
// 启动 TSS 子进程 - sign 命令
const args = [
'sign',
'--session-id', sessionId,
'--party-id', partyId,
'--party-index', partyIndex.toString(),
'--threshold-t', threshold.t.toString(),
'--threshold-n', threshold.n.toString(),
'--participants', participantsJson,
'--message-hash', messageHash,
'--share-data', shareData,
'--password', sharePassword,
];
console.log(`[TSS-SIGN] Spawning: ${binaryPath} sign ...`);
this.tssProcess = spawn(binaryPath, args);
let resultData = '';
let stderrData = '';
// 如果没有预订阅,现在订阅消息
if (!wasPrepared) {
console.log('[TSS-SIGN] Subscribing to messages (not prepared before)');
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
this.grpcClient.subscribeMessages(sessionId, partyId);
} else {
console.log(`[TSS-SIGN] Using existing subscription, ${bufferedCount} messages buffered`);
}
// 处理标准输出 (JSON 消息)
this.tssProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(line => line.trim());
// 收到第一条输出时,标记进程就绪并发送缓冲的消息
if (!this.isProcessReady && this.tssProcess?.stdin) {
this.isProcessReady = true;
console.log(`[TSS-SIGN] Process ready, flushing ${this.messageBuffer.length} buffered messages`);
this.flushMessageBuffer();
}
for (const line of lines) {
try {
const message: TSSMessage = JSON.parse(line);
this.handleTSSMessage(message);
if (message.type === 'result') {
resultData = line;
}
} catch {
// 非 JSON 输出,记录日志
console.log('[TSS-SIGN]', line);
}
}
});
// 处理标准错误
this.tssProcess.stderr?.on('data', (data: Buffer) => {
const errorText = data.toString();
stderrData += errorText;
console.error('[TSS-SIGN stderr]', errorText);
});
// 处理进程退出
this.tssProcess.on('close', (code) => {
const completedSessionId = this.sessionId;
this.isRunning = false;
this.isProcessReady = false;
this.isPrepared = false;
this.messageBuffer = [];
this.tssProcess = null;
// 清理消息监听器
this.grpcClient.removeAllListeners('mpcMessage');
if (code === 0 && resultData) {
try {
const result: TSSMessage = JSON.parse(resultData);
if (result.payload) {
// 成功完成后清理该会话的已处理消息记录
if (this.database && completedSessionId) {
this.database.clearProcessedMessages(completedSessionId);
}
resolve({
success: true,
signature: Buffer.from(result.payload, 'base64'),
});
} else {
reject(new Error(result.error || 'Sign failed: no result data'));
}
} catch (e) {
reject(new Error(`Failed to parse sign result: ${e}`));
}
} else {
const errorMsg = stderrData.trim() || `Sign process exited with code ${code}`;
console.error(`[TSS-SIGN] Process failed: code=${code}, stderr=${stderrData}`);
reject(new Error(errorMsg));
}
});
// 处理进程错误
this.tssProcess.on('error', (err) => {
this.isRunning = false;
this.isProcessReady = false;
this.isPrepared = false;
this.messageBuffer = [];
this.tssProcess = null;
// 清理消息监听器
this.grpcClient.removeAllListeners('mpcMessage');
reject(err);
});
} catch (err) {
this.isRunning = false;
this.isPrepared = false;
reject(err);
}
});
}
/** /**
* *
*/ */

View File

@ -63,7 +63,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
register: (partyId: string, role: string) => register: (partyId: string, role: string) =>
ipcRenderer.invoke('grpc:register', { partyId, role }), ipcRenderer.invoke('grpc:register', { partyId, role }),
// 签名相关 // 签名相关 (旧版 - persistent 签名使用)
validateSigningSession: (code: string) => validateSigningSession: (code: string) =>
ipcRenderer.invoke('grpc:validateSigningSession', { code }), ipcRenderer.invoke('grpc:validateSigningSession', { code }),
@ -88,6 +88,53 @@ contextBridge.exposeInMainWorld('electronAPI', {
}, },
}, },
// ===========================================================================
// Co-Sign 相关 - 全新的多方协作签名 API
// ===========================================================================
cosign: {
// 创建 Co-Sign 会话
createSession: (params: {
shareId: string;
sharePassword: string;
messageHash: string;
initiatorName?: string;
}) => ipcRenderer.invoke('cosign:createSession', params),
// 验证邀请码
validateInviteCode: (code: string) =>
ipcRenderer.invoke('cosign:validateInviteCode', { code }),
// 加入 Co-Sign 会话
joinSession: (params: {
sessionId: string;
shareId: string;
sharePassword: string;
joinToken: string;
walletName?: string;
messageHash: string;
threshold: { t: number; n: number };
}) => ipcRenderer.invoke('cosign:joinSession', params),
// 获取会话状态
getSessionStatus: (sessionId: string) =>
ipcRenderer.invoke('cosign:getSessionStatus', { sessionId }),
// 订阅会话事件
subscribeSessionEvents: (sessionId: string, callback: (event: unknown) => void) => {
const channel = `cosign:events:${sessionId}`;
const listener = (_event: unknown, data: unknown) => callback(data);
eventSubscriptions.set(channel, listener);
ipcRenderer.on(channel, listener);
ipcRenderer.send('cosign:subscribeSessionEvents', { sessionId });
return () => {
ipcRenderer.removeListener(channel, listener);
eventSubscriptions.delete(channel);
ipcRenderer.send('cosign:unsubscribeSessionEvents', { sessionId });
};
},
},
// =========================================================================== // ===========================================================================
// Account 服务相关 (HTTP API) // Account 服务相关 (HTTP API)
// =========================================================================== // ===========================================================================

View File

@ -9,6 +9,10 @@ import Create from './pages/Create';
import Session from './pages/Session'; import Session from './pages/Session';
import Sign from './pages/Sign'; import Sign from './pages/Sign';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
// Co-Sign 页面
import CoSignCreate from './pages/CoSignCreate';
import CoSignJoin from './pages/CoSignJoin';
import CoSignSession from './pages/CoSignSession';
function App() { function App() {
const [startupComplete, setStartupComplete] = useState(false); const [startupComplete, setStartupComplete] = useState(false);
@ -37,12 +41,20 @@ function App() {
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
{/* Keygen 路由 */}
<Route path="/join" element={<Join />} /> <Route path="/join" element={<Join />} />
<Route path="/join/:inviteCode" element={<Join />} /> <Route path="/join/:inviteCode" element={<Join />} />
<Route path="/create" element={<Create />} /> <Route path="/create" element={<Create />} />
<Route path="/session/:sessionId" element={<Session />} /> <Route path="/session/:sessionId" element={<Session />} />
{/* 旧版签名路由 (persistent) */}
<Route path="/sign" element={<Sign />} /> <Route path="/sign" element={<Sign />} />
<Route path="/sign/:sessionId" element={<Sign />} /> <Route path="/sign/:sessionId" element={<Sign />} />
{/* Co-Sign 路由 */}
<Route path="/cosign/create" element={<CoSignCreate />} />
<Route path="/cosign/join" element={<CoSignJoin />} />
<Route path="/cosign/join/:inviteCode" element={<CoSignJoin />} />
<Route path="/cosign/session/:sessionId" element={<CoSignSession />} />
{/* 设置 */}
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@ -0,0 +1,265 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import styles from './Create.module.css';
interface Share {
id: string;
walletName: string;
publicKey: string;
threshold: { t: number; n: number };
createdAt: string;
}
interface CreateCoSignResult {
success: boolean;
sessionId?: string;
inviteCode?: string;
error?: string;
}
export default function CoSignCreate() {
const navigate = useNavigate();
const [shares, setShares] = useState<Share[]>([]);
const [selectedShareId, setSelectedShareId] = useState('');
const [sharePassword, setSharePassword] = useState('');
const [messageHash, setMessageHash] = useState('');
const [participantName, setParticipantName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<CreateCoSignResult | null>(null);
const [step, setStep] = useState<'config' | 'creating' | 'created'>('config');
// 加载本地 shares
useEffect(() => {
const loadShares = async () => {
try {
const result = await window.electronAPI.storage.listShares();
// 兼容不同返回格式
const shareList = Array.isArray(result) ? result : ((result as any)?.data || []);
setShares(shareList);
if (shareList.length > 0) {
setSelectedShareId(shareList[0].id);
}
} catch (err) {
console.error('Failed to load shares:', err);
}
};
loadShares();
}, []);
const handleCreateSession = async () => {
if (!selectedShareId) {
setError('请选择一个钱包');
return;
}
if (!messageHash.trim()) {
setError('请输入待签名的消息哈希');
return;
}
if (!/^[0-9a-fA-F]{64}$/.test(messageHash.trim())) {
setError('消息哈希必须是 64 位十六进制字符串 (32 字节)');
return;
}
if (!participantName.trim()) {
setParticipantName('发起者');
}
setStep('creating');
setIsLoading(true);
setError(null);
try {
const createResult = await window.electronAPI.cosign.createSession({
shareId: selectedShareId,
sharePassword: sharePassword,
messageHash: messageHash.trim().toLowerCase(),
initiatorName: participantName.trim() || '发起者',
});
if (createResult.success) {
setResult(createResult);
setStep('created');
} else {
setError(createResult.error || '创建签名会话失败');
setStep('config');
}
} catch (err) {
setError('创建签名会话失败,请检查网络连接');
setStep('config');
} finally {
setIsLoading(false);
}
};
const handleCopyInviteCode = async () => {
if (result?.inviteCode) {
try {
await navigator.clipboard.writeText(result.inviteCode);
} catch (err) {
console.error('Failed to copy:', err);
}
}
};
const handleGoToSession = () => {
if (result?.sessionId) {
navigate(`/cosign/session/${result.sessionId}`);
}
};
const selectedShare = shares.find(s => s.id === selectedShareId);
return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}></p>
{step === 'config' && (
<div className={styles.form}>
{/* 签名说明 */}
<div className={styles.infoBox}>
<div className={styles.infoIcon}>i</div>
<div className={styles.infoContent}>
<strong></strong>
<ul className={styles.infoList}>
<li><strong></strong>: 使</li>
<li><strong></strong>: SHA256 </li>
<li><strong></strong>: </li>
<li><strong></strong>: </li>
</ul>
</div>
</div>
{/* 选择钱包 */}
<div className={styles.inputGroup}>
<label className={styles.label}></label>
{shares.length === 0 ? (
<p className={styles.hint}></p>
) : (
<select
value={selectedShareId}
onChange={(e) => setSelectedShareId(e.target.value)}
className={styles.input}
disabled={isLoading}
>
{shares.map(share => (
<option key={share.id} value={share.id}>
{share.walletName} ({share.threshold.t}-of-{share.threshold.n})
</option>
))}
</select>
)}
{selectedShare && (
<p className={styles.hint}>
: {selectedShare.publicKey.substring(0, 16)}...
</p>
)}
</div>
{/* 钱包密码 */}
<div className={styles.inputGroup}>
<label className={styles.label}> ()</label>
<input
type="password"
value={sharePassword}
onChange={(e) => setSharePassword(e.target.value)}
placeholder="如果设置了密码,请输入"
className={styles.input}
disabled={isLoading}
/>
</div>
{/* 消息哈希 */}
<div className={styles.inputGroup}>
<label className={styles.label}> (Hex)</label>
<input
type="text"
value={messageHash}
onChange={(e) => setMessageHash(e.target.value)}
placeholder="64位十六进制字符串如: a1b2c3d4..."
className={styles.input}
disabled={isLoading}
/>
<p className={styles.hint}>
SHA256 (32 = 64 )
</p>
</div>
{/* 参与者名称 */}
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<input
type="text"
value={participantName}
onChange={(e) => setParticipantName(e.target.value)}
placeholder="输入您的名称(其他参与者可见)"
className={styles.input}
disabled={isLoading}
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.secondaryButton}
onClick={() => navigate('/')}
disabled={isLoading}
>
</button>
<button
className={styles.primaryButton}
onClick={handleCreateSession}
disabled={isLoading || shares.length === 0}
>
</button>
</div>
</div>
)}
{step === 'creating' && (
<div className={styles.creating}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
)}
{step === 'created' && result && (
<div className={styles.form}>
<div className={styles.successIcon}>OK</div>
<h3 className={styles.successTitle}></h3>
<div className={styles.inviteSection}>
<label className={styles.label}></label>
<div className={styles.inviteCodeWrapper}>
<code className={styles.inviteCode}>{result.inviteCode}</code>
<button
className={styles.copyButton}
onClick={handleCopyInviteCode}
>
</button>
</div>
<p className={styles.hint}>
使
</p>
</div>
<div className={styles.actions}>
<button
className={styles.primaryButton}
onClick={handleGoToSession}
>
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,343 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import styles from './Join.module.css';
interface Share {
id: string;
walletName: string;
publicKey: string;
sessionId: string;
threshold: { t: number; n: number };
}
interface SessionInfo {
sessionId: string;
keygenSessionId: string;
walletName: string;
messageHash: string;
threshold: { t: number; n: number };
status: string;
currentParticipants: number;
}
interface ValidateResult {
success: boolean;
error?: string;
sessionInfo?: SessionInfo;
joinToken?: string;
}
export default function CoSignJoin() {
const { inviteCode } = useParams<{ inviteCode?: string }>();
const navigate = useNavigate();
const [code, setCode] = useState(inviteCode || '');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
const [joinToken, setJoinToken] = useState<string | null>(null);
const [step, setStep] = useState<'input' | 'select_share' | 'joining'>('input');
// Share 选择相关
const [shares, setShares] = useState<Share[]>([]);
const [selectedShareId, setSelectedShareId] = useState('');
const [sharePassword, setSharePassword] = useState('');
const [autoJoinAttempted, setAutoJoinAttempted] = useState(false);
// 加载本地 shares
useEffect(() => {
const loadShares = async () => {
try {
const result = await window.electronAPI.storage.listShares();
// 兼容不同返回格式
const shareList = Array.isArray(result) ? result : ((result as any)?.data || []);
setShares(shareList);
} catch (err) {
console.error('Failed to load shares:', err);
}
};
loadShares();
}, []);
// 如果 URL 中有邀请码,自动验证
useEffect(() => {
if (inviteCode) {
handleValidateCode(inviteCode);
}
}, [inviteCode]);
// 自动选择匹配的 share
useEffect(() => {
if (sessionInfo && shares.length > 0 && !selectedShareId) {
// 尝试找到匹配的 share基于 keygen session ID
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
if (matchingShare) {
setSelectedShareId(matchingShare.id);
} else if (shares.length === 1) {
// 如果只有一个 share自动选择
setSelectedShareId(shares[0].id);
}
}
}, [sessionInfo, shares, selectedShareId]);
// 自动加入
useEffect(() => {
if (
step === 'select_share' &&
sessionInfo &&
joinToken &&
selectedShareId &&
!autoJoinAttempted &&
!isLoading
) {
// 找到匹配的 share 且未尝试过自动加入,则自动加入
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
if (matchingShare && matchingShare.id === selectedShareId) {
setAutoJoinAttempted(true);
handleJoinSession();
}
}
}, [step, sessionInfo, joinToken, selectedShareId, autoJoinAttempted, isLoading, shares]);
const handleValidateCode = async (codeToValidate: string) => {
if (!codeToValidate.trim()) {
setError('请输入邀请码');
return;
}
setIsLoading(true);
setError(null);
try {
const result: ValidateResult = await window.electronAPI.cosign.validateInviteCode(codeToValidate);
if (result.success && result.sessionInfo) {
setSessionInfo(result.sessionInfo);
if (result.joinToken) {
setJoinToken(result.joinToken);
}
setStep('select_share');
} else {
setError(result.error || '无效的邀请码');
}
} catch (err) {
setError('验证邀请码失败,请检查网络连接');
} finally {
setIsLoading(false);
}
};
const handleJoinSession = async () => {
if (!sessionInfo) {
setError('会话信息不完整');
return;
}
if (!joinToken) {
setError('未获取到加入令牌,请重新验证邀请码');
return;
}
if (!selectedShareId) {
setError('请选择一个钱包');
return;
}
setStep('joining');
setIsLoading(true);
setError(null);
try {
const result = await window.electronAPI.cosign.joinSession({
sessionId: sessionInfo.sessionId,
shareId: selectedShareId,
sharePassword: sharePassword,
joinToken: joinToken,
walletName: sessionInfo.walletName,
messageHash: sessionInfo.messageHash,
threshold: sessionInfo.threshold,
});
if (result.success) {
navigate(`/cosign/session/${sessionInfo.sessionId}`);
} else {
setError(result.error || '加入会话失败');
setStep('select_share');
}
} catch (err) {
setError('加入会话失败,请重试');
setStep('select_share');
} finally {
setIsLoading(false);
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
setCode(text.trim());
} catch (err) {
console.error('Failed to read clipboard:', err);
}
};
const selectedShare = shares.find(s => s.id === selectedShareId);
return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}></p>
{step === 'input' && (
<div className={styles.form}>
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<div className={styles.inputWrapper}>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="粘贴签名邀请码"
className={styles.input}
disabled={isLoading}
/>
<button
className={styles.pasteButton}
onClick={handlePaste}
disabled={isLoading}
>
</button>
</div>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.secondaryButton}
onClick={() => navigate('/')}
disabled={isLoading}
>
</button>
<button
className={styles.primaryButton}
onClick={() => handleValidateCode(code)}
disabled={isLoading || !code.trim()}
>
{isLoading ? '验证中...' : '下一步'}
</button>
</div>
</div>
)}
{step === 'select_share' && sessionInfo && (
<div className={styles.form}>
<div className={styles.sessionInfo}>
<h3 className={styles.sessionTitle}></h3>
<div className={styles.infoGrid}>
<div className={styles.infoItem}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue}>{sessionInfo.walletName}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue}>
{sessionInfo.threshold.t}-of-{sessionInfo.threshold.n}
</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue} style={{ fontFamily: 'monospace', fontSize: '12px' }}>
{sessionInfo.messageHash.substring(0, 16)}...
</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue}>
{sessionInfo.currentParticipants} / {sessionInfo.threshold.t}
</span>
</div>
</div>
</div>
{/* 选择本地 share */}
<div className={styles.inputGroup}>
<label className={styles.label}></label>
{shares.length === 0 ? (
<p className={styles.hint}></p>
) : (
<select
value={selectedShareId}
onChange={(e) => setSelectedShareId(e.target.value)}
className={styles.input}
disabled={isLoading}
>
<option value="">...</option>
{shares.map(share => (
<option key={share.id} value={share.id}>
{share.walletName} ({share.threshold.t}-of-{share.threshold.n})
{share.sessionId === sessionInfo.keygenSessionId ? ' [匹配]' : ''}
</option>
))}
</select>
)}
{selectedShare && (
<p className={styles.hint}>
: {selectedShare.publicKey.substring(0, 16)}...
</p>
)}
</div>
{/* 钱包密码 */}
<div className={styles.inputGroup}>
<label className={styles.label}> ()</label>
<input
type="password"
value={sharePassword}
onChange={(e) => setSharePassword(e.target.value)}
placeholder="如果设置了密码,请输入"
className={styles.input}
disabled={isLoading}
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.secondaryButton}
onClick={() => {
setStep('input');
setSessionInfo(null);
setJoinToken(null);
setSelectedShareId('');
setError(null);
setAutoJoinAttempted(false);
}}
disabled={isLoading}
>
</button>
<button
className={styles.primaryButton}
onClick={handleJoinSession}
disabled={isLoading || !selectedShareId}
>
{isLoading ? '加入中...' : '加入签名'}
</button>
</div>
</div>
)}
{step === 'joining' && (
<div className={styles.joining}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,329 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import styles from './Session.module.css';
interface Participant {
partyId: string;
name: string;
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
}
interface SessionState {
sessionId: string;
walletName: string;
messageHash: string;
threshold: { t: number; n: number };
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
participants: Participant[];
currentRound: number;
totalRounds: number;
signature?: string;
error?: string;
}
export default function CoSignSession() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const [session, setSession] = useState<SessionState | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchSessionStatus = useCallback(async () => {
if (!sessionId) return;
try {
const result = await window.electronAPI.cosign.getSessionStatus(sessionId);
if (result.success && result.session) {
setSession({
sessionId: result.session.sessionId || sessionId,
walletName: result.session.walletName || '',
messageHash: result.session.messageHash || '',
threshold: result.session.threshold || { t: 0, n: 0 },
status: mapStatus(result.session.status),
participants: result.session.participants || [],
currentRound: 0,
totalRounds: 9, // GG20 签名有 9 轮
});
} else {
setError(result.error || '获取会话状态失败');
}
} catch (err) {
setError('获取会话状态失败');
} finally {
setIsLoading(false);
}
}, [sessionId]);
// 映射后端状态到前端状态
const mapStatus = (status: string): 'waiting' | 'ready' | 'processing' | 'completed' | 'failed' => {
switch (status) {
case 'pending':
case 'waiting':
return 'waiting';
case 'all_joined':
case 'ready':
return 'ready';
case 'in_progress':
case 'signing':
return 'processing';
case 'completed':
return 'completed';
case 'failed':
case 'expired':
return 'failed';
default:
return 'waiting';
}
};
useEffect(() => {
fetchSessionStatus();
// 订阅会话事件
const unsubscribe = window.electronAPI.cosign.subscribeSessionEvents(
sessionId!,
(event: any) => {
console.log('[CoSignSession] Received event:', event);
if (event.type === 'participant_joined') {
setSession(prev => prev ? {
...prev,
participants: event.participant
? [...prev.participants, event.participant]
: prev.participants,
} : null);
// 刷新状态
fetchSessionStatus();
} else if (event.type === 'status_changed' || event.type === 'all_joined') {
setSession(prev => prev ? {
...prev,
status: event.status ? mapStatus(event.status) : prev.status,
} : null);
// 刷新状态
fetchSessionStatus();
} else if (event.type === 'progress') {
setSession(prev => prev ? {
...prev,
status: 'processing',
currentRound: event.round || prev.currentRound,
totalRounds: event.totalRounds || prev.totalRounds,
} : null);
} else if (event.type === 'completed') {
setSession(prev => prev ? {
...prev,
status: 'completed',
signature: event.signature,
} : null);
} else if (event.type === 'failed' || event.type === 'sign_start_timeout') {
setSession(prev => prev ? {
...prev,
status: 'failed',
error: event.error,
} : null);
}
}
);
return () => {
unsubscribe();
};
}, [sessionId, fetchSessionStatus]);
const getStatusText = (status: string) => {
switch (status) {
case 'waiting': return '等待参与方';
case 'ready': return '准备就绪';
case 'processing': return '签名进行中';
case 'completed': return '签名完成';
case 'failed': return '签名失败';
default: return status;
}
};
const getStatusClass = (status: string) => {
switch (status) {
case 'waiting': return styles.statusWaiting;
case 'ready': return styles.statusReady;
case 'processing': return styles.statusProcessing;
case 'completed': return styles.statusCompleted;
case 'failed': return styles.statusFailed;
default: return '';
}
};
const getParticipantStatusIcon = (status: string) => {
switch (status) {
case 'waiting': return '...';
case 'ready': return 'OK';
case 'processing': return '*';
case 'completed': return 'OK';
case 'failed': return 'X';
default: return '-';
}
};
const handleCopySignature = async () => {
if (session?.signature) {
try {
await navigator.clipboard.writeText(session.signature);
} catch (err) {
console.error('Failed to copy:', err);
}
}
};
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
</div>
);
}
if (error || !session) {
return (
<div className={styles.container}>
<div className={styles.error}>
<div className={styles.errorIcon}>!</div>
<h3></h3>
<p>{error || '无法获取会话信息'}</p>
<button className={styles.primaryButton} onClick={() => navigate('/')}>
</button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>{session.walletName || '多方签名'}</h1>
<p className={styles.sessionId}> ID: {session.sessionId?.substring(0, 16)}...</p>
</div>
<span className={`${styles.status} ${getStatusClass(session.status)}`}>
{getStatusText(session.status)}
</span>
</div>
<div className={styles.content}>
{/* 消息哈希 */}
{session.messageHash && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<code style={{
display: 'block',
padding: '8px 12px',
backgroundColor: 'var(--background-color)',
borderRadius: 'var(--radius-md)',
fontSize: '12px',
wordBreak: 'break-all',
fontFamily: 'monospace',
}}>
{session.messageHash}
</code>
</div>
)}
{/* 进度部分 */}
{session.status === 'processing' && (
<div className={styles.progress}>
<div className={styles.progressHeader}>
<span></span>
<span>{session.currentRound} / {session.totalRounds}</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${(session.currentRound / session.totalRounds) * 100}%` }}
></div>
</div>
</div>
)}
{/* 参与方列表 */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>
({(session.participants || []).length} / {session.threshold?.t || 0})
</h3>
<div className={styles.participantList}>
{(session.participants || []).map((participant, index) => (
<div key={participant.partyId || index} className={styles.participant}>
<div className={styles.participantInfo}>
<span className={styles.participantIndex}>#{index + 1}</span>
<span className={styles.participantName}>{participant.name || `参与方 ${index + 1}`}</span>
</div>
<span className={`${styles.participantStatus} ${getStatusClass(participant.status)}`}>
{getParticipantStatusIcon(participant.status)}
</span>
</div>
))}
{Array.from({ length: Math.max(0, (session.threshold?.t || 0) - (session.participants || []).length) }).map((_, index) => (
<div key={`empty-${index}`} className={`${styles.participant} ${styles.participantEmpty}`}>
<div className={styles.participantInfo}>
<span className={styles.participantIndex}>#{(session.participants || []).length + index + 1}</span>
<span className={styles.participantName}>...</span>
</div>
<span className={styles.participantStatus}>...</span>
</div>
))}
</div>
</div>
{/* 阈值信息 */}
{session.threshold && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<div className={styles.thresholdInfo}>
<span className={styles.thresholdBadge}>
{session.threshold.t}-of-{session.threshold.n}
</span>
<span className={styles.thresholdText}>
{session.threshold.t}
</span>
</div>
</div>
)}
{/* 完成状态 */}
{session.status === 'completed' && session.signature && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<div className={styles.publicKeyWrapper}>
<code className={styles.publicKey}>{session.signature}</code>
<button className={styles.copyButton} onClick={handleCopySignature}>
</button>
</div>
<p className={styles.successMessage}>
OK
</p>
</div>
)}
{/* 失败状态 */}
{session.status === 'failed' && session.error && (
<div className={styles.section}>
<div className={styles.failureMessage}>
<span className={styles.failureIcon}>!</span>
<span>{session.error}</span>
</div>
</div>
)}
</div>
<div className={styles.footer}>
<button className={styles.primaryButton} onClick={() => navigate('/')}>
</button>
</div>
</div>
</div>
);
}

View File

@ -410,6 +410,93 @@ interface KavaHealthCheckResult {
// Electron API 接口 // Electron API 接口
// =========================================================================== // ===========================================================================
// ===========================================================================
// Co-Sign 相关类型
// ===========================================================================
interface CoSignSessionInfo {
sessionId: string;
keygenSessionId: string;
walletName: string;
messageHash: string;
threshold: { t: number; n: number };
status: string;
currentParticipants: number;
parties?: Array<{ party_id: string; party_index: number }>;
}
interface CreateCoSignSessionParams {
shareId: string;
sharePassword: string;
messageHash: string;
initiatorName?: string;
}
interface CreateCoSignSessionResult {
success: boolean;
sessionId?: string;
inviteCode?: string;
walletName?: string;
expiresAt?: string;
error?: string;
}
interface ValidateCoSignInviteCodeResult {
success: boolean;
sessionInfo?: CoSignSessionInfo;
joinToken?: string;
error?: string;
}
interface JoinCoSignSessionParams {
sessionId: string;
shareId: string;
sharePassword: string;
joinToken: string;
walletName?: string;
messageHash: string;
threshold: { t: number; n: number };
}
interface JoinCoSignSessionResult {
success: boolean;
data?: unknown;
error?: string;
}
interface GetCoSignSessionStatusResult {
success: boolean;
session?: {
sessionId: string;
status: string;
joinedCount: number;
threshold: { t: number; n: number };
participants: Array<{
partyId: string;
partyIndex: number;
name: string;
status: string;
}>;
messageHash: string;
walletName: string;
};
error?: string;
}
interface CoSignSessionEvent {
type: 'participant_joined' | 'status_changed' | 'all_joined' | 'progress' | 'completed' | 'failed' | 'sign_start_timeout';
participant?: {
partyId: string;
name: string;
status: string;
};
status?: string;
round?: number;
totalRounds?: number;
signature?: string;
error?: string;
}
interface ElectronAPI { interface ElectronAPI {
// Account 服务相关 (HTTP API) // Account 服务相关 (HTTP API)
account: { account: {
@ -521,6 +608,15 @@ interface ElectronAPI {
unsubscribeLogs: () => void; unsubscribeLogs: () => void;
log: (level: string, source: string, message: string) => void; log: (level: string, source: string, message: string) => void;
}; };
// Co-Sign 相关 (多方协作签名)
cosign: {
createSession: (params: CreateCoSignSessionParams) => Promise<CreateCoSignSessionResult>;
validateInviteCode: (code: string) => Promise<ValidateCoSignInviteCodeResult>;
joinSession: (params: JoinCoSignSessionParams) => Promise<JoinCoSignSessionResult>;
getSessionStatus: (sessionId: string) => Promise<GetCoSignSessionStatusResult>;
subscribeSessionEvents: (sessionId: string, callback: (event: CoSignSessionEvent) => void) => () => void;
};
} }
declare global { declare global {

View File

@ -6,7 +6,10 @@ require github.com/bnb-chain/tss-lib/v2 v2.0.2
require ( require (
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
github.com/btcsuite/btcd v0.23.4 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
github.com/btcsuite/btcutil v1.0.2 // indirect
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
@ -20,6 +23,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect go.uber.org/zap v1.26.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/sys v0.15.0 // indirect golang.org/x/sys v0.15.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
) )

View File

@ -7,6 +7,7 @@ github.com/bnb-chain/tss-lib/v2 v2.0.2 h1:dL2GJFCSYsYQ0bHkGll+hNM2JWsC1rxDmJJJQE
github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8= github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
@ -15,9 +16,11 @@ github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
@ -144,6 +147,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=

View File

@ -0,0 +1,674 @@
// integration_test.go - Integration test for the complete co-sign flow
// Tests: session creation, joining, waiting, events, and signing
package main
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"os"
"os/exec"
"sync"
"testing"
"time"
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
"github.com/bnb-chain/tss-lib/v2/tss"
)
const (
// Backend service URLs (from docker-compose.windows.yml)
accountServiceURL = "http://localhost:4000"
grpcRouterAddr = "localhost:50051"
)
// API Response types for co-managed keygen
type CreateCoManagedSessionRequest struct {
WalletName string `json:"wallet_name"`
ThresholdT int `json:"threshold_t"`
ThresholdN int `json:"threshold_n"`
InitiatorPartyID string `json:"initiator_party_id"`
InitiatorName string `json:"initiator_name,omitempty"`
PersistentCount int `json:"persistent_count"`
}
type CreateCoManagedSessionResponse struct {
SessionID string `json:"session_id"`
InviteCode string `json:"invite_code"`
WalletName string `json:"wallet_name"`
ThresholdT int `json:"threshold_t"`
ThresholdN int `json:"threshold_n"`
ExpiresAt int64 `json:"expires_at"`
JoinToken string `json:"join_token"`
PartyID string `json:"party_id"`
PartyIndex int `json:"party_index"`
}
// API Response types for co-managed sign
type CreateSignSessionRequest struct {
KeygenSessionID string `json:"keygen_session_id"`
WalletName string `json:"wallet_name"`
MessageHash string `json:"message_hash"`
Parties []SignPartyInfo `json:"parties"`
ThresholdT int `json:"threshold_t"`
InitiatorName string `json:"initiator_name,omitempty"`
}
type CreateSignSessionResponse struct {
SessionID string `json:"session_id"`
InviteCode string `json:"invite_code"`
KeygenSessionID string `json:"keygen_session_id"`
MessageHash string `json:"message_hash"`
ThresholdT int `json:"threshold_t"`
ExpiresAt int64 `json:"expires_at"`
JoinToken string `json:"join_token"`
}
type GetSignSessionResponse struct {
SessionID string `json:"session_id"`
KeygenSessionID string `json:"keygen_session_id"`
WalletName string `json:"wallet_name"`
MessageHash string `json:"message_hash"`
ThresholdT int `json:"threshold_t"`
Status string `json:"status"`
InviteCode string `json:"invite_code"`
ExpiresAt int64 `json:"expires_at"`
Parties []SignPartyInfo `json:"parties"`
JoinedCount int `json:"joined_count"`
JoinToken string `json:"join_token,omitempty"`
}
type SignPartyInfo struct {
PartyID string `json:"party_id"`
PartyIndex int `json:"party_index"`
}
// TestAccountServiceHealth tests if account service is available
func TestAccountServiceHealth(t *testing.T) {
resp, err := http.Get(accountServiceURL + "/health")
if err != nil {
t.Fatalf("Failed to connect to account service: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("Account service unhealthy, status: %d", resp.StatusCode)
}
t.Log("Account service is healthy")
}
// TestCreateSignSession tests creating a new sign session
func TestCreateSignSession(t *testing.T) {
// This requires an existing keygen session ID
// For testing, we'll use a mock one
keygenSessionID := "test-keygen-session-" + fmt.Sprintf("%d", time.Now().UnixNano())
messageHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
reqBody := CreateSignSessionRequest{
KeygenSessionID: keygenSessionID,
MessageHash: messageHash,
InitiatorName: "test-initiator",
}
jsonBody, _ := json.Marshal(reqBody)
resp, err := http.Post(
accountServiceURL+"/api/v1/co-managed/sign",
"application/json",
bytes.NewBuffer(jsonBody),
)
if err != nil {
t.Fatalf("Failed to create sign session: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Create session response (status %d): %s", resp.StatusCode, string(body))
if resp.StatusCode != 200 && resp.StatusCode != 201 {
t.Logf("Note: This test requires an existing keygen session in the database")
t.Skipf("Sign session creation returned status %d (expected existing keygen session)", resp.StatusCode)
}
var result CreateSignSessionResponse
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
t.Logf("Session created: ID=%s, InviteCode=%s", result.SessionID, result.InviteCode)
}
// TestGetSessionByInviteCode tests retrieving session info by invite code
func TestGetSessionByInviteCode(t *testing.T) {
// This would need a valid invite code from a real session
inviteCode := "TEST123"
resp, err := http.Get(accountServiceURL + "/api/v1/co-managed/sign/by-invite-code/" + inviteCode)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Get session response (status %d): %s", resp.StatusCode, string(body))
if resp.StatusCode == 404 {
t.Skip("No session found with invite code (expected for test)")
}
}
// TestCoManagedKeygenSessionFlow tests the keygen session creation and join flow
func TestCoManagedKeygenSessionFlow(t *testing.T) {
t.Log("=== Co-Managed Keygen Session Flow Test ===")
// Step 1: Create a keygen session
t.Log("Step 1: Creating keygen session...")
reqBody := CreateCoManagedSessionRequest{
WalletName: "test-wallet-" + fmt.Sprintf("%d", time.Now().UnixNano()),
ThresholdT: 1, // 2-of-3: t=1 means t+1=2 signers needed
ThresholdN: 3,
InitiatorPartyID: "test-initiator-party",
InitiatorName: "Test User",
PersistentCount: 0, // No server parties for this test
}
jsonBody, _ := json.Marshal(reqBody)
resp, err := http.Post(
accountServiceURL+"/api/v1/co-managed/sessions",
"application/json",
bytes.NewBuffer(jsonBody),
)
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Create session response (status %d): %s", resp.StatusCode, string(body))
if resp.StatusCode != 200 && resp.StatusCode != 201 {
t.Fatalf("Failed to create keygen session, status: %d", resp.StatusCode)
}
var createResp CreateCoManagedSessionResponse
if err := json.Unmarshal(body, &createResp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
t.Logf("Session created: ID=%s, InviteCode=%s", createResp.SessionID, createResp.InviteCode)
// Step 2: Get session by invite code
t.Log("Step 2: Getting session by invite code...")
resp2, err := http.Get(accountServiceURL + "/api/v1/co-managed/sessions/by-invite-code/" + createResp.InviteCode)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
t.Logf("Get session response (status %d): %s", resp2.StatusCode, string(body2))
if resp2.StatusCode != 200 {
t.Fatalf("Failed to get session by invite code, status: %d", resp2.StatusCode)
}
// Step 3: Get session status
t.Log("Step 3: Getting session status...")
resp3, err := http.Get(accountServiceURL + "/api/v1/co-managed/sessions/" + createResp.SessionID)
if err != nil {
t.Fatalf("Failed to get session status: %v", err)
}
defer resp3.Body.Close()
body3, _ := io.ReadAll(resp3.Body)
t.Logf("Session status response (status %d): %s", resp3.StatusCode, string(body3))
if resp3.StatusCode != 200 {
t.Fatalf("Failed to get session status, status: %d", resp3.StatusCode)
}
t.Log("Keygen session flow test passed!")
}
// TestCoManagedSignSessionFlow tests the full sign session flow
func TestCoManagedSignSessionFlow(t *testing.T) {
t.Log("=== Co-Managed Sign Session Flow Test ===")
// First, create a keygen session to get a valid keygen_session_id
t.Log("Step 1: Creating keygen session...")
keygenReq := CreateCoManagedSessionRequest{
WalletName: "test-wallet-for-sign-" + fmt.Sprintf("%d", time.Now().UnixNano()),
ThresholdT: 1,
ThresholdN: 3,
InitiatorPartyID: "test-party-0",
InitiatorName: "Test User",
PersistentCount: 0,
}
jsonBody, _ := json.Marshal(keygenReq)
resp, err := http.Post(
accountServiceURL+"/api/v1/co-managed/sessions",
"application/json",
bytes.NewBuffer(jsonBody),
)
if err != nil {
t.Fatalf("Failed to create keygen session: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Keygen session response (status %d): %s", resp.StatusCode, string(body))
if resp.StatusCode != 200 && resp.StatusCode != 201 {
t.Fatalf("Failed to create keygen session, status: %d", resp.StatusCode)
}
var keygenResp CreateCoManagedSessionResponse
if err := json.Unmarshal(body, &keygenResp); err != nil {
t.Fatalf("Failed to parse keygen response: %v", err)
}
// Step 2: Create a sign session using the keygen session ID
// Note: For threshold T, we need T+1 parties to sign
// Backend validates that parties.length >= threshold_t + 1
t.Log("Step 2: Creating sign session...")
signReq := CreateSignSessionRequest{
KeygenSessionID: keygenResp.SessionID,
WalletName: keygenReq.WalletName,
MessageHash: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
ThresholdT: 1, // Threshold T=1 means T+1=2 parties needed to sign
Parties: []SignPartyInfo{
{PartyID: "test-party-0", PartyIndex: 0},
{PartyID: "test-party-1", PartyIndex: 1},
},
InitiatorName: "Test User",
}
jsonBody2, _ := json.Marshal(signReq)
resp2, err := http.Post(
accountServiceURL+"/api/v1/co-managed/sign",
"application/json",
bytes.NewBuffer(jsonBody2),
)
if err != nil {
t.Fatalf("Failed to create sign session: %v", err)
}
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
t.Logf("Sign session response (status %d): %s", resp2.StatusCode, string(body2))
if resp2.StatusCode != 200 && resp2.StatusCode != 201 {
t.Fatalf("Failed to create sign session, status: %d", resp2.StatusCode)
}
var signResp CreateSignSessionResponse
if err := json.Unmarshal(body2, &signResp); err != nil {
t.Fatalf("Failed to parse sign response: %v", err)
}
t.Logf("Sign session created: ID=%s, InviteCode=%s", signResp.SessionID, signResp.InviteCode)
// Step 3: Get sign session by invite code
t.Log("Step 3: Getting sign session by invite code...")
resp3, err := http.Get(accountServiceURL + "/api/v1/co-managed/sign/by-invite-code/" + signResp.InviteCode)
if err != nil {
t.Fatalf("Failed to get sign session: %v", err)
}
defer resp3.Body.Close()
body3, _ := io.ReadAll(resp3.Body)
t.Logf("Get sign session response (status %d): %s", resp3.StatusCode, string(body3))
if resp3.StatusCode != 200 {
t.Fatalf("Failed to get sign session by invite code, status: %d", resp3.StatusCode)
}
var getSignResp GetSignSessionResponse
if err := json.Unmarshal(body3, &getSignResp); err != nil {
t.Fatalf("Failed to parse get sign session response: %v", err)
}
t.Logf("Sign session status: %s, JoinedCount: %d, ThresholdT: %d",
getSignResp.Status, getSignResp.JoinedCount, getSignResp.ThresholdT)
t.Log("Sign session flow test passed!")
}
// TestFullSignFlow tests the complete signing flow with mock data
func TestFullSignFlow(t *testing.T) {
t.Log("=== Full Co-Sign Flow Test ===")
// Step 1: Generate mock key shares (simulating keygen result)
// NOTE: In tss-lib, threshold parameter means "t" where you need t+1 parties to sign.
// So for 2-of-3, we use threshold=1 (meaning 1+1=2 parties needed to sign)
t.Log("Step 1: Generating mock key shares for 2-of-3 scheme...")
thresholdT := 1 // t value: need t+1=2 parties to sign
totalN := 3 // total parties
shares, err := generateMockKeyShares(thresholdT, totalN)
if err != nil {
t.Fatalf("Failed to generate key shares: %v", err)
}
t.Logf("Generated %d key shares", len(shares))
// Step 2: Prepare signing parameters
messageHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
sessionID := fmt.Sprintf("test-sign-session-%d", time.Now().UnixNano())
password := "test-password"
// Step 3: Start signing with 2 parties (index 0 and 1)
// For tss-party.exe, we pass the signing threshold (t+1=2) and total (n=3)
t.Log("Step 2: Starting sign process with 2 parties...")
signingParties := []int{0, 1}
// Pass thresholdT+1=2 as the signing threshold to tss-party.exe
signature, err := runSigningProcess(shares, signingParties, messageHash, sessionID, password, thresholdT+1, totalN, t)
if err != nil {
t.Fatalf("Signing failed: %v", err)
}
t.Logf("Step 3: Signing complete!")
t.Logf("Signature (hex): %x", signature)
t.Logf("Signature (base64): %s", base64.StdEncoding.EncodeToString(signature))
}
// generateMockKeyShares generates key shares using tss-lib directly
func generateMockKeyShares(threshold, total int) ([]*keygen.LocalPartySaveData, error) {
fmt.Println("[Keygen] Starting key generation for", total, "parties with threshold", threshold)
partyIDs := make([]*tss.PartyID, total)
for i := 0; i < total; i++ {
partyIDs[i] = tss.NewPartyID(
fmt.Sprintf("party-%d", i),
fmt.Sprintf("party-%d", i),
big.NewInt(int64(i+1)),
)
}
sortedPartyIDs := tss.SortPartyIDs(partyIDs)
peerCtx := tss.NewPeerContext(sortedPartyIDs)
outChannels := make([]chan tss.Message, total)
endChannels := make([]chan *keygen.LocalPartySaveData, total)
parties := make([]tss.Party, total)
for i := 0; i < total; i++ {
outChannels[i] = make(chan tss.Message, total*20)
endChannels[i] = make(chan *keygen.LocalPartySaveData, 1)
params := tss.NewParameters(tss.S256(), peerCtx, sortedPartyIDs[i], total, threshold)
parties[i] = keygen.NewLocalParty(params, outChannels[i], endChannels[i])
}
// Start all parties
var wg sync.WaitGroup
errChan := make(chan error, total)
for i := 0; i < total; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
if err := parties[idx].Start(); err != nil {
errChan <- fmt.Errorf("party %d start error: %w", idx, err)
}
}(i)
}
results := make([]*keygen.LocalPartySaveData, total)
var resultsMu sync.Mutex
resultCount := 0
done := make(chan struct{})
// Message routing
go func() {
for {
select {
case <-done:
return
default:
for i := 0; i < total; i++ {
select {
case msg := <-outChannels[i]:
wire, _, _ := msg.WireBytes()
if msg.IsBroadcast() {
for j := 0; j < total; j++ {
if j != i {
go func(destIdx int) {
parsed, _ := tss.ParseWireMessage(wire, msg.GetFrom(), true)
parties[destIdx].Update(parsed)
}(j)
}
}
} else {
for _, to := range msg.GetTo() {
for j := 0; j < total; j++ {
if sortedPartyIDs[j].Id == to.Id {
go func(destIdx int) {
parsed, _ := tss.ParseWireMessage(wire, msg.GetFrom(), false)
parties[destIdx].Update(parsed)
}(j)
break
}
}
}
}
case result := <-endChannels[i]:
resultsMu.Lock()
results[i] = result
resultCount++
fmt.Printf("[Keygen] Party %d completed\n", i)
if resultCount == total {
close(done)
}
resultsMu.Unlock()
default:
}
}
time.Sleep(5 * time.Millisecond)
}
}
}()
wg.Wait()
select {
case <-done:
fmt.Println("[Keygen] All parties completed successfully")
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("keygen timeout")
}
select {
case err := <-errChan:
return nil, err
default:
}
return results, nil
}
// runSigningProcess runs the signing process using tss-party.exe
func runSigningProcess(
shares []*keygen.LocalPartySaveData,
signingPartyIndices []int,
messageHash, sessionID, password string,
thresholdT, thresholdN int,
t *testing.T,
) ([]byte, error) {
// Build participants
participants := make([]Participant, len(signingPartyIndices))
for i, idx := range signingPartyIndices {
participants[i] = Participant{
PartyID: fmt.Sprintf("party-%d", idx),
PartyIndex: idx,
}
}
participantsJSON, _ := json.Marshal(participants)
// Prepare encrypted shares
encryptedShares := make([]string, len(signingPartyIndices))
for i, idx := range signingPartyIndices {
shareBytes, _ := json.Marshal(shares[idx])
encrypted := encryptShare(shareBytes, password)
encryptedShares[i] = base64.StdEncoding.EncodeToString(encrypted)
}
// Find tss-party executable
exePath := "./tss-party.exe"
if _, err := os.Stat(exePath); os.IsNotExist(err) {
exePath = "./tss-party"
if _, err := os.Stat(exePath); os.IsNotExist(err) {
return nil, fmt.Errorf("tss-party executable not found")
}
}
t.Logf("Using executable: %s", exePath)
// Start processes
processes := make([]*exec.Cmd, len(signingPartyIndices))
stdinPipes := make([]io.WriteCloser, len(signingPartyIndices))
stdoutPipes := make([]io.ReadCloser, len(signingPartyIndices))
for i, idx := range signingPartyIndices {
args := []string{
"sign",
"-session-id", sessionID,
"-party-id", fmt.Sprintf("party-%d", idx),
"-party-index", fmt.Sprintf("%d", idx),
"-threshold-t", fmt.Sprintf("%d", thresholdT),
"-threshold-n", fmt.Sprintf("%d", thresholdN),
"-participants", string(participantsJSON),
"-message-hash", messageHash,
"-share-data", encryptedShares[i],
"-password", password,
}
t.Logf("[Party %d] Starting with args: sign -session-id %s -party-id party-%d ...", idx, sessionID, idx)
cmd := exec.Command(exePath, args...)
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
cmd.Stderr = os.Stderr
processes[i] = cmd
stdinPipes[i] = stdin
stdoutPipes[i] = stdout
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start party %d: %w", idx, err)
}
}
// Message routing between parties
var wg sync.WaitGroup
results := make([][]byte, len(signingPartyIndices))
errors := make([]error, len(signingPartyIndices))
// Create a mutex for stdin writes
stdinMutexes := make([]*sync.Mutex, len(signingPartyIndices))
for i := range stdinMutexes {
stdinMutexes[i] = &sync.Mutex{}
}
for i := range signingPartyIndices {
wg.Add(1)
go func(partyIdx int) {
defer wg.Done()
scanner := bufio.NewScanner(stdoutPipes[partyIdx])
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
var msg Message
if err := json.Unmarshal([]byte(line), &msg); err != nil {
t.Logf("[Party %d] Invalid JSON: %s", signingPartyIndices[partyIdx], line)
continue
}
switch msg.Type {
case "progress":
t.Logf("[Party %d] Progress: round %d/%d", signingPartyIndices[partyIdx], msg.Round, msg.TotalRounds)
case "outgoing":
// tss-party.exe outputs "outgoing" messages that need to be routed to other parties
t.Logf("[Party %d] Outgoing message (broadcast=%v, toParties=%v)", signingPartyIndices[partyIdx], msg.IsBroadcast, msg.ToParties)
// Route to other parties
for j := range signingPartyIndices {
if j != partyIdx {
// If not broadcast, check if this party is in the ToParties list
if !msg.IsBroadcast && len(msg.ToParties) > 0 {
targetPartyID := fmt.Sprintf("party-%d", signingPartyIndices[j])
found := false
for _, to := range msg.ToParties {
if to == targetPartyID {
found = true
break
}
}
if !found {
continue
}
}
// tss-party.exe expects "incoming" messages
msgToSend := Message{
Type: "incoming",
IsBroadcast: msg.IsBroadcast,
Payload: msg.Payload,
FromPartyIndex: signingPartyIndices[partyIdx],
}
data, _ := json.Marshal(msgToSend)
stdinMutexes[j].Lock()
stdinPipes[j].Write(append(data, '\n'))
stdinMutexes[j].Unlock()
}
}
case "result":
t.Logf("[Party %d] Got result!", signingPartyIndices[partyIdx])
signature, _ := base64.StdEncoding.DecodeString(msg.Payload)
results[partyIdx] = signature
case "error":
t.Logf("[Party %d] Error: %s", signingPartyIndices[partyIdx], msg.Error)
errors[partyIdx] = fmt.Errorf("party error: %s", msg.Error)
}
}
if err := scanner.Err(); err != nil {
t.Logf("[Party %d] Scanner error: %v", signingPartyIndices[partyIdx], err)
}
}(i)
}
// Wait for processes to complete
for i, cmd := range processes {
if err := cmd.Wait(); err != nil {
t.Logf("[Party %d] Process exit error: %v", signingPartyIndices[i], err)
}
}
wg.Wait()
// Check for errors
for i, err := range errors {
if err != nil {
return nil, fmt.Errorf("party %d error: %w", signingPartyIndices[i], err)
}
}
// Return first result
for _, result := range results {
if result != nil {
return result, nil
}
}
return nil, fmt.Errorf("no signature received")
}

View File

@ -8,6 +8,7 @@ import (
"bufio" "bufio"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
@ -18,7 +19,9 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/bnb-chain/tss-lib/v2/common"
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen" "github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
"github.com/bnb-chain/tss-lib/v2/ecdsa/signing"
"github.com/bnb-chain/tss-lib/v2/tss" "github.com/bnb-chain/tss-lib/v2/tss"
) )
@ -118,16 +121,85 @@ func runKeygen() {
} }
func runSign() { func runSign() {
// TODO: Implement signing // Parse sign flags
sendError("Signing not implemented yet") fs := flag.NewFlagSet("sign", flag.ExitOnError)
sessionID := fs.String("session-id", "", "Session ID")
partyID := fs.String("party-id", "", "Party ID")
partyIndex := fs.Int("party-index", 0, "Party index (0-based)")
thresholdT := fs.Int("threshold-t", 0, "Threshold T")
thresholdN := fs.Int("threshold-n", 0, "Threshold N (total parties in keygen)")
participantsJSON := fs.String("participants", "[]", "Participants JSON array")
messageHash := fs.String("message-hash", "", "Message hash to sign (hex encoded)")
shareData := fs.String("share-data", "", "Encrypted share data (base64 encoded)")
password := fs.String("password", "", "Password to decrypt share")
if err := fs.Parse(os.Args[2:]); err != nil {
sendError(fmt.Sprintf("Failed to parse flags: %v", err))
os.Exit(1) os.Exit(1)
} }
// Validate required fields
if *sessionID == "" || *partyID == "" || *thresholdT == 0 || *thresholdN == 0 {
sendError("Missing required parameters")
os.Exit(1)
}
if *messageHash == "" {
sendError("Missing message hash")
os.Exit(1)
}
if *shareData == "" {
sendError("Missing share data")
os.Exit(1)
}
// Parse participants
var participants []Participant
if err := json.Unmarshal([]byte(*participantsJSON), &participants); err != nil {
sendError(fmt.Sprintf("Failed to parse participants: %v", err))
os.Exit(1)
}
// Note: For signing, participant count equals threshold T (not N)
// because only T parties participate in signing
if len(participants) != *thresholdT {
sendError(fmt.Sprintf("Participant count mismatch: got %d, expected %d (threshold T)", len(participants), *thresholdT))
os.Exit(1)
}
// Run sign protocol
result, err := executeSign(
*sessionID,
*partyID,
*partyIndex,
*thresholdT,
*thresholdN,
participants,
*messageHash,
*shareData,
*password,
)
if err != nil {
sendError(fmt.Sprintf("Sign failed: %v", err))
os.Exit(1)
}
// Send result
sendSignResult(result.Signature, result.RecoveryID, *partyIndex)
}
type keygenResult struct { type keygenResult struct {
PublicKey []byte PublicKey []byte
EncryptedShare []byte EncryptedShare []byte
} }
type signResult struct {
Signature []byte
RecoveryID int
}
func executeKeygen( func executeKeygen(
sessionID, partyID string, sessionID, partyID string,
partyIndex, thresholdT, thresholdN int, partyIndex, thresholdT, thresholdN int,
@ -404,3 +476,230 @@ func sendResult(publicKey, encryptedShare []byte, partyIndex int) {
data, _ := json.Marshal(msg) data, _ := json.Marshal(msg)
fmt.Println(string(data)) fmt.Println(string(data))
} }
func sendSignResult(signature []byte, recoveryID int, partyIndex int) {
msg := Message{
Type: "result",
Payload: base64.StdEncoding.EncodeToString(signature),
PartyIndex: partyIndex,
}
data, _ := json.Marshal(msg)
fmt.Println(string(data))
}
func decryptShare(encryptedData []byte, password string) ([]byte, error) {
// Match the encryption format: first 32 bytes are password hash, rest is data
if len(encryptedData) < 32 {
return nil, fmt.Errorf("encrypted data too short")
}
// Verify password (simple check - matches encryptShare)
expectedHash := hashPassword(password)
actualHash := encryptedData[:32]
// Simple comparison
match := true
for i := 0; i < 32; i++ {
if expectedHash[i] != actualHash[i] {
match = false
break
}
}
if !match {
return nil, fmt.Errorf("incorrect password")
}
return encryptedData[32:], nil
}
func executeSign(
sessionID, partyID string,
partyIndex, thresholdT, thresholdN int,
participants []Participant,
messageHashHex string,
shareDataBase64 string,
password string,
) (*signResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Handle signals for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
// Decode and decrypt share data
encryptedShare, err := base64.StdEncoding.DecodeString(shareDataBase64)
if err != nil {
return nil, fmt.Errorf("failed to decode share data: %w", err)
}
shareBytes, err := decryptShare(encryptedShare, password)
if err != nil {
return nil, fmt.Errorf("failed to decrypt share: %w", err)
}
// Parse keygen save data
var keygenData keygen.LocalPartySaveData
if err := json.Unmarshal(shareBytes, &keygenData); err != nil {
return nil, fmt.Errorf("failed to parse keygen data: %w", err)
}
// Decode message hash
messageHash, err := hex.DecodeString(messageHashHex)
if err != nil {
return nil, fmt.Errorf("failed to decode message hash: %w", err)
}
if len(messageHash) != 32 {
return nil, fmt.Errorf("message hash must be 32 bytes, got %d", len(messageHash))
}
msgBigInt := new(big.Int).SetBytes(messageHash)
// Create TSS party IDs for signing participants
// IMPORTANT: For tss-lib signing, we must reconstruct the party IDs in the same way
// as during keygen. The signing subset (T parties) must use their original keys from keygen.
//
// The keygenData.Ks contains the public keys for all N parties from keygen.
// We need to create party IDs that match the original keygen party structure,
// but only include the T parties that are participating in this signing session.
// Create party IDs only for the signing participants
tssPartyIDs := make([]*tss.PartyID, 0, len(participants))
var selfTSSID *tss.PartyID
for _, p := range participants {
// Use the keygen key at this party's index
// The party key in tss-lib uses the key's big.Int representation
partyKey := tss.NewPartyID(
p.PartyID,
fmt.Sprintf("party-%d", p.PartyIndex),
big.NewInt(int64(p.PartyIndex+1)),
)
tssPartyIDs = append(tssPartyIDs, partyKey)
if p.PartyID == partyID {
selfTSSID = partyKey
}
}
if selfTSSID == nil {
return nil, fmt.Errorf("self party not found in participants")
}
// Sort party IDs (important for tss-lib)
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Create peer context and parameters
// For signing with T parties from an N-party keygen:
// - The peer context contains only the T signing parties
// - threshold parameter should be T-1 (since we need T parties to sign, threshold = T-1)
peerCtx := tss.NewPeerContext(sortedPartyIDs)
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), thresholdT-1)
// Create channels
outCh := make(chan tss.Message, thresholdT*10)
endCh := make(chan *common.SignatureData, 1)
errCh := make(chan error, 1)
// Create local party for signing
localParty := signing.NewLocalParty(msgBigInt, params, keygenData, outCh, endCh)
// Build party index map for incoming messages
partyIndexMap := make(map[int]*tss.PartyID)
for _, p := range sortedPartyIDs {
for _, orig := range participants {
if orig.PartyID == p.Id {
partyIndexMap[orig.PartyIndex] = p
break
}
}
}
// Start the local party
go func() {
if err := localParty.Start(); err != nil {
errCh <- err
}
}()
// Handle outgoing messages
var outWg sync.WaitGroup
outWg.Add(1)
go func() {
defer outWg.Done()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-outCh:
if !ok {
return
}
handleOutgoingMessage(msg)
}
}
}()
// Handle incoming messages from stdin
var inWg sync.WaitGroup
inWg.Add(1)
go func() {
defer inWg.Done()
scanner := bufio.NewScanner(os.Stdin)
// 增加 buffer 大小到 1MB
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
}
var msg Message
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
continue
}
if msg.Type == "incoming" {
handleIncomingMessage(msg, localParty, partyIndexMap, errCh)
}
}
}()
// Track progress - GG20 signing has 9 rounds
totalRounds := 9
// Wait for completion
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errCh:
return nil, err
case sigData := <-endCh:
// Signing completed successfully
sendProgress(totalRounds, totalRounds)
// Construct signature in DER format or raw R||S format
// sigData contains R, S, and recovery ID
rBytes := sigData.R
sBytes := sigData.S
// Create raw signature: R (32 bytes) || S (32 bytes)
signature := make([]byte, 64)
copy(signature[32-len(rBytes):32], rBytes)
copy(signature[64-len(sBytes):64], sBytes)
// Recovery ID for Ethereum-style signatures
recoveryID := int(sigData.SignatureRecovery[0])
return &signResult{
Signature: signature,
RecoveryID: recoveryID,
}, nil
}
}