fix(tss-android): 修复备份恢复后无法签名的问题

问题原因:
备份数据中缺少 partyId 字段。恢复到新手机后,签名时使用的是新设备
生成的 partyId,而不是 keygen 时编码到 LocalPartySaveData 中的
原始 partyId,导致 TSS 签名协议无法正确匹配密钥数据而失败。

修复内容:
1. Models.kt:
   - ShareRecord 添加 partyId 字段
   - ShareBackup 添加 partyId 字段,备份格式版本升级到 v2
   - 更新 fromShareRecord() 和 toShareRecord() 方法

2. Database.kt:
   - ShareRecordEntity 添加 party_id 列
   - 数据库版本升级到 3

3. AppModule.kt:
   - 添加 MIGRATION_2_3 数据库迁移脚本

4. TssRepository.kt:
   - 添加 currentSigningPartyId 成员变量跟踪当前签名使用的 partyId
   - keygen 保存时包含 partyId (3处)
   - 备份导入时保存原始 partyId
   - 签名流程使用 shareEntity.partyId 替代设备 partyId (3处)
   - gRPC 调用 (markPartyReady, reportCompletion) 使用原始 partyId

关键点: 签名时必须使用 keygen 时的原始 partyId,因为该 ID 被编码
到了 TSS 密钥数据结构中。现在备份会保存此关键字段,恢复后签名
将使用正确的 partyId。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-19 18:56:27 -08:00
parent b00de68b01
commit a47b935bce
4 changed files with 75 additions and 23 deletions

View File

@ -29,6 +29,9 @@ data class ShareRecordEntity(
@ColumnInfo(name = "party_index")
val partyIndex: Int,
@ColumnInfo(name = "party_id")
val partyId: String, // The original partyId used during keygen - required for signing
@ColumnInfo(name = "address")
val address: String,
@ -95,7 +98,7 @@ interface AppSettingDao {
*/
@Database(
entities = [ShareRecordEntity::class, AppSettingEntity::class],
version = 2,
version = 3, // Version 3: added party_id column to share_records
exportSchema = false
)
abstract class TssDatabase : RoomDatabase() {

View File

@ -48,6 +48,12 @@ class TssRepository @Inject constructor(
// partyId is loaded once from database in registerParty() and cached here
// This matches Electron's getOrCreatePartyId() pattern
private lateinit var partyId: String
// currentSigningPartyId: The partyId to use for the current signing session
// This may differ from partyId when signing with a restored wallet backup
// CRITICAL: For backup/restore to work, signing must use the original partyId from keygen
private var currentSigningPartyId: String? = null
private var messageCollectionJob: Job? = null
private var sessionEventJob: Job? = null
@ -1051,6 +1057,7 @@ class TssRepository @Inject constructor(
val address = AddressUtils.deriveEvmAddress(publicKeyBytes)
// Save share record (use actual thresholds and party index from backend)
// CRITICAL: Save partyId - this is required for signing after backup/restore
val shareEntity = ShareRecordEntity(
sessionId = sessionId,
publicKey = result.publicKey,
@ -1058,6 +1065,7 @@ class TssRepository @Inject constructor(
thresholdT = actualThresholdT,
thresholdN = actualThresholdN,
partyIndex = actualPartyIndex,
partyId = partyId,
address = address
)
val id = shareRecordDao.insertShare(shareEntity)
@ -1222,10 +1230,14 @@ class TssRepository @Inject constructor(
} else {
messageHash
}
android.util.Log.d("TssRepository", "Starting TSS sign with cleanMessageHash=${cleanMessageHash.take(20)}...")
// CRITICAL: Use shareEntity.partyId (original partyId from keygen) for signing
// This is required for backup/restore to work - the partyId must match what was used during keygen
val signingPartyId = shareEntity.partyId
currentSigningPartyId = signingPartyId // Save for later use in this flow
android.util.Log.d("TssRepository", "Starting TSS sign with cleanMessageHash=${cleanMessageHash.take(20)}..., signingPartyId=$signingPartyId")
val startResult = tssNativeBridge.startSign(
sessionId = sessionId,
partyId = partyId,
partyId = signingPartyId,
partyIndex = partyIndex,
thresholdT = thresholdT,
thresholdN = shareEntity.thresholdN, // Use original N from keygen
@ -1243,8 +1255,8 @@ class TssRepository @Inject constructor(
// Start collecting progress from native bridge
startProgressCollection()
// Mark ready
grpcClient.markPartyReady(sessionId, partyId)
// Mark ready - use signingPartyId (original partyId from keygen)
grpcClient.markPartyReady(sessionId, signingPartyId)
// Wait for sign result
val signResult = tssNativeBridge.waitForSignResult()
@ -1256,14 +1268,15 @@ class TssRepository @Inject constructor(
val result = signResult.getOrThrow()
// Report completion
// Report completion - use signingPartyId (original partyId from keygen)
val signatureBytes = android.util.Base64.decode(result.signature, android.util.Base64.NO_WRAP)
grpcClient.reportCompletion(sessionId, partyId, signature = signatureBytes)
grpcClient.reportCompletion(sessionId, signingPartyId, signature = signatureBytes)
stopProgressCollection()
_sessionStatus.value = SessionStatus.COMPLETED
pendingSessionId = null // Clear pending session ID on completion
messageCollectionJob?.cancel()
currentSigningPartyId = null // Clear after signing completes
android.util.Log.d("TssRepository", "Sign as joiner completed: signature=${result.signature.take(20)}...")
@ -1274,6 +1287,7 @@ class TssRepository @Inject constructor(
stopProgressCollection()
_sessionStatus.value = SessionStatus.FAILED
pendingSessionId = null // Clear pending session ID on failure
currentSigningPartyId = null // Clear on failure too
Result.failure(e)
}
}
@ -1366,6 +1380,7 @@ class TssRepository @Inject constructor(
val address = AddressUtils.deriveEvmAddress(publicKeyBytes)
// Save share record
// CRITICAL: Save partyId - this is required for signing after backup/restore
val shareEntity = ShareRecordEntity(
sessionId = apiJoinData.sessionId,
publicKey = result.publicKey,
@ -1373,6 +1388,7 @@ class TssRepository @Inject constructor(
thresholdT = apiJoinData.thresholdT,
thresholdN = apiJoinData.thresholdN,
partyIndex = myPartyIndex,
partyId = partyId,
address = address
)
val id = shareRecordDao.insertShare(shareEntity)
@ -1516,12 +1532,15 @@ class TssRepository @Inject constructor(
_sessionStatus.value = SessionStatus.WAITING
// Add self to participants
val allParticipants = sessionData.participants + Participant(partyId, myPartyIndex)
// CRITICAL: Use shareEntity.partyId (original partyId from keygen) for signing
val signingPartyId = shareEntity.partyId
currentSigningPartyId = signingPartyId // Save for later use in this flow
val allParticipants = sessionData.participants + Participant(signingPartyId, myPartyIndex)
// Start TSS sign
val startResult = tssNativeBridge.startSign(
sessionId = apiJoinData.sessionId,
partyId = partyId,
partyId = signingPartyId,
partyIndex = myPartyIndex,
thresholdT = apiJoinData.thresholdT,
thresholdN = shareEntity.thresholdN, // Use original N from keygen
@ -1540,8 +1559,8 @@ class TssRepository @Inject constructor(
// Start message routing
startMessageRouting(apiJoinData.sessionId, myPartyIndex)
// Mark ready
grpcClient.markPartyReady(apiJoinData.sessionId, partyId)
// Mark ready - use signingPartyId (original partyId from keygen)
grpcClient.markPartyReady(apiJoinData.sessionId, signingPartyId)
// Wait for sign result
val signResult = tssNativeBridge.waitForSignResult()
@ -1552,18 +1571,20 @@ class TssRepository @Inject constructor(
val result = signResult.getOrThrow()
// Report completion
// Report completion - use signingPartyId (original partyId from keygen)
val signatureBytes = Base64.decode(result.signature, Base64.NO_WRAP)
grpcClient.reportCompletion(apiJoinData.sessionId, partyId, signature = signatureBytes)
grpcClient.reportCompletion(apiJoinData.sessionId, signingPartyId, signature = signatureBytes)
_sessionStatus.value = SessionStatus.COMPLETED
messageCollectionJob?.cancel()
currentSigningPartyId = null // Clear after signing completes
Result.success(result)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Join sign session failed", e)
_sessionStatus.value = SessionStatus.FAILED
currentSigningPartyId = null // Clear on failure too
Result.failure(e)
}
}
@ -1785,6 +1806,7 @@ class TssRepository @Inject constructor(
val address = AddressUtils.deriveEvmAddress(publicKeyBytes)
// Save share record (use actual thresholds from backend)
// CRITICAL: Save partyId - this is required for signing after backup/restore
val shareEntity = ShareRecordEntity(
sessionId = sessionId,
publicKey = result.publicKey,
@ -1792,6 +1814,7 @@ class TssRepository @Inject constructor(
thresholdT = actualThresholdT,
thresholdN = actualThresholdN,
partyIndex = myPartyIndex,
partyId = partyId,
address = address
)
val id = shareRecordDao.insertShare(shareEntity)
@ -1900,6 +1923,7 @@ class TssRepository @Inject constructor(
}
// Convert to entity and save
// CRITICAL: Preserve the original partyId from backup - this is required for signing
val shareRecord = backup.toShareRecord()
val entity = ShareRecordEntity(
sessionId = shareRecord.sessionId,
@ -1908,6 +1932,7 @@ class TssRepository @Inject constructor(
thresholdT = shareRecord.thresholdT,
thresholdN = shareRecord.thresholdN,
partyIndex = shareRecord.partyIndex,
partyId = shareRecord.partyId,
address = shareRecord.address,
createdAt = shareRecord.createdAt
)
@ -1915,7 +1940,7 @@ class TssRepository @Inject constructor(
val newId = shareRecordDao.insertShare(entity)
val savedShare = shareRecord.copy(id = newId)
android.util.Log.d("TssRepository", "Imported share backup for address: ${backup.address}")
android.util.Log.d("TssRepository", "Imported share backup for address: ${backup.address}, partyId: ${backup.partyId}")
Result.success(savedShare)
} catch (e: com.google.gson.JsonSyntaxException) {
android.util.Log.e("TssRepository", "Invalid JSON format in backup", e)
@ -2312,8 +2337,12 @@ class TssRepository @Inject constructor(
val shareEntity = shareRecordDao.getShareById(shareId)
?: return@withContext Result.failure(Exception("Share not found"))
// CRITICAL: Use shareEntity.partyId (original partyId from keygen) for signing
// This is required for backup/restore to work - the partyId must match what was used during keygen
val signingPartyId = shareEntity.partyId
currentSigningPartyId = signingPartyId // Save for waitForSignature
android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: participants=${session.participants.size}")
android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: sessionId=$sessionId, partyId=$partyId, partyIndex=${shareEntity.partyIndex}")
android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: sessionId=$sessionId, signingPartyId=$signingPartyId, partyIndex=${shareEntity.partyIndex}")
android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: thresholdT=${session.thresholdT}, thresholdN=${shareEntity.thresholdN}")
android.util.Log.d("TssRepository", "[CO-SIGN] startSigning: messageHash=${session.messageHash?.take(20)}...")
session.participants.forEachIndexed { idx, p ->
@ -2328,10 +2357,10 @@ class TssRepository @Inject constructor(
} else {
rawMessageHash
}
android.util.Log.d("TssRepository", "[CO-SIGN] Calling tssNativeBridge.startSign with cleanMessageHash=${cleanMessageHash.take(20)}...")
android.util.Log.d("TssRepository", "[CO-SIGN] Calling tssNativeBridge.startSign with cleanMessageHash=${cleanMessageHash.take(20)}..., signingPartyId=$signingPartyId")
val startResult = tssNativeBridge.startSign(
sessionId = sessionId,
partyId = partyId,
partyId = signingPartyId,
partyIndex = shareEntity.partyIndex,
thresholdT = session.thresholdT,
thresholdN = shareEntity.thresholdN,
@ -2359,8 +2388,8 @@ class TssRepository @Inject constructor(
startMessageRouting(sessionId, shareEntity.partyIndex)
}
// Mark ready
grpcClient.markPartyReady(sessionId, partyId)
// Mark ready - use signingPartyId (original partyId from keygen)
grpcClient.markPartyReady(sessionId, signingPartyId)
Result.success(Unit)
} catch (e: Exception) {
@ -2386,16 +2415,18 @@ class TssRepository @Inject constructor(
val result = signResult.getOrThrow()
// Report completion
// Report completion - use currentSigningPartyId (original partyId from keygen)
val signatureBytes = Base64.decode(result.signature, Base64.NO_WRAP)
val session = _currentSession.value
val signingPartyId = currentSigningPartyId ?: partyId
if (session != null) {
grpcClient.reportCompletion(session.sessionId, partyId, signature = signatureBytes)
grpcClient.reportCompletion(session.sessionId, signingPartyId, signature = signatureBytes)
}
stopProgressCollection()
_sessionStatus.value = SessionStatus.COMPLETED
messageCollectionJob?.cancel()
currentSigningPartyId = null // Clear after signing completes
Result.success(result)
} catch (e: Exception) {
@ -2759,6 +2790,7 @@ private fun ShareRecordEntity.toShareRecord() = ShareRecord(
thresholdT = thresholdT,
thresholdN = thresholdN,
partyIndex = partyIndex,
partyId = partyId,
address = address,
createdAt = createdAt
)

View File

@ -34,6 +34,17 @@ object AppModule {
}
}
// Migration from version 2 to 3: add party_id column to share_records
// This is critical for backup/restore - the partyId must be preserved for signing to work
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
// Add party_id column with empty default (existing records will need to be re-exported)
database.execSQL(
"ALTER TABLE `share_records` ADD COLUMN `party_id` TEXT NOT NULL DEFAULT ''"
)
}
}
@Provides
@Singleton
fun provideGson(): Gson {
@ -48,7 +59,7 @@ object AppModule {
TssDatabase::class.java,
"tss_party.db"
)
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
}

View File

@ -86,6 +86,7 @@ data class ShareRecord(
val thresholdT: Int,
val thresholdN: Int,
val partyIndex: Int,
val partyId: String, // The original partyId used during keygen - required for signing
val address: String,
val createdAt: Long = System.currentTimeMillis()
)
@ -165,7 +166,7 @@ data class WalletBalance(
*/
data class ShareBackup(
@SerializedName("version")
val version: Int = 1, // Backup format version for future compatibility
val version: Int = 2, // Version 2: added partyId field for proper backup/restore
@SerializedName("sessionId")
val sessionId: String,
@ -185,6 +186,9 @@ data class ShareBackup(
@SerializedName("partyIndex")
val partyIndex: Int,
@SerializedName("partyId")
val partyId: String, // The original partyId used during keygen - CRITICAL for signing after restore
@SerializedName("address")
val address: String,
@ -209,6 +213,7 @@ data class ShareBackup(
thresholdT = share.thresholdT,
thresholdN = share.thresholdN,
partyIndex = share.partyIndex,
partyId = share.partyId,
address = share.address,
createdAt = share.createdAt
)
@ -227,6 +232,7 @@ data class ShareBackup(
thresholdT = thresholdT,
thresholdN = thresholdN,
partyIndex = partyIndex,
partyId = partyId,
address = address,
createdAt = createdAt
)