From 5f58044e73cfd14e9335cf0e3006aefa6abff743 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Fri, 24 Jul 2026 15:41:46 -0400 Subject: [PATCH] handle tss txp deletion; add cli session restoration; bug fixes --- packages/bitcore-cli/src/cli.ts | 5 +- .../src/commands/create/createThresholdSig.ts | 11 +-- packages/bitcore-cli/src/errors.ts | 5 ++ packages/bitcore-cli/src/filestorage.ts | 8 ++ packages/bitcore-cli/src/tss.ts | 86 ++++++++++++------- packages/bitcore-cli/src/wallet.ts | 6 ++ .../src/lib/common/encryption.ts | 6 +- .../src/lib/credentials.ts | 1 + .../bitcore-wallet-client/src/lib/tsskey.ts | 2 + .../bitcore-wallet-client/src/lib/tsssign.ts | 23 +++-- .../lib/routes/middleware/authTssRequest.ts | 2 +- .../bitcore-wallet-service/src/lib/server.ts | 12 ++- .../bitcore-wallet-service/src/lib/storage.ts | 4 + 13 files changed, 121 insertions(+), 50 deletions(-) diff --git a/packages/bitcore-cli/src/cli.ts b/packages/bitcore-cli/src/cli.ts index 5d803aa1594..658285b2ad5 100755 --- a/packages/bitcore-cli/src/cli.ts +++ b/packages/bitcore-cli/src/cli.ts @@ -214,8 +214,9 @@ if (require.main === module) { // ...update status cmdParams.status = await wallet.client.getStatus({ tokenAddress: tokenObj?.contractAddress }); } - cmdParams.opts.tokenAddress = tokenObj?.contractAddress; - cmdParams.opts.token = tokenObj?.displayCode; + // Update persistent opts (cmdParams.opts gets reset to opts since is susceptible to being overwritten in other commands) + opts.tokenAddress = tokenObj?.contractAddress; + opts.token = tokenObj?.displayCode; break; case 'address': await commands.address.createAddress(cmdParams); diff --git a/packages/bitcore-cli/src/commands/create/createThresholdSig.ts b/packages/bitcore-cli/src/commands/create/createThresholdSig.ts index 63bdd666ca5..a659008bf13 100644 --- a/packages/bitcore-cli/src/commands/create/createThresholdSig.ts +++ b/packages/bitcore-cli/src/commands/create/createThresholdSig.ts @@ -115,16 +115,10 @@ export async function createThresholdSigWallet( } while (joinCodeAction !== 'continue'); } - const spinner = prompt.spinner({ indicator: 'timer' }); + const spinner = prompt.spinner({ indicator: 'timer', onCancel: () => { tss.unsubscribe(); } }); spinner.start('Waiting for all parties to join...'); await new Promise((resolve, reject) => { - process.on('SIGINT', () => { - tss.unsubscribe(); - spinner.stop('Cancelled by user'); - reject(new UserCancelled()); - }); - tss.subscribe({ walletName: wallet.name, copayerName, @@ -161,6 +155,9 @@ export async function createThresholdSigWallet( reject(err); } }); + tss.on('unsubscribe', () => { + reject(new UserCancelled()); + }); }); diff --git a/packages/bitcore-cli/src/errors.ts b/packages/bitcore-cli/src/errors.ts index 8c993a5004e..c4935e269c6 100644 --- a/packages/bitcore-cli/src/errors.ts +++ b/packages/bitcore-cli/src/errors.ts @@ -4,3 +4,8 @@ export class UserCancelled extends Error { } }; +export class ProcessCancelled extends Error { + constructor() { + super('Cancelled by process'); + } +} \ No newline at end of file diff --git a/packages/bitcore-cli/src/filestorage.ts b/packages/bitcore-cli/src/filestorage.ts index 38a01994b1c..6fbef1246a4 100644 --- a/packages/bitcore-cli/src/filestorage.ts +++ b/packages/bitcore-cli/src/filestorage.ts @@ -35,4 +35,12 @@ export class FileStorage { exists() { return fs.existsSync(this.filename); } + + async getStatePath() { + const walletName = path.basename(this.filename, path.extname(this.filename)); + const statePath = path.join(path.dirname(this.filename), '.state', walletName); + // Ensure state directory exists + await fs.promises.mkdir(statePath, { recursive: true }); + return statePath; + } }; \ No newline at end of file diff --git a/packages/bitcore-cli/src/tss.ts b/packages/bitcore-cli/src/tss.ts index eb0187b77bd..30c79a1ad5b 100644 --- a/packages/bitcore-cli/src/tss.ts +++ b/packages/bitcore-cli/src/tss.ts @@ -1,12 +1,14 @@ +import fs from 'fs'; +import path from 'path'; import url from 'url'; -import { TssSign } from '@bitpay-labs/bitcore-wallet-client'; +import { Encryption, Errors, TssSign } from '@bitpay-labs/bitcore-wallet-client'; import { type Types as CWCTypes, Transactions } from '@bitpay-labs/crypto-wallet-core'; import * as prompt from '@clack/prompts'; import { type TssKeyType, type WalletData } from '../types/wallet'; -import { UserCancelled } from './errors'; +import { ProcessCancelled, UserCancelled } from './errors'; /** * Sign a message using TSS @@ -16,6 +18,7 @@ export async function sign(args: { host: string; chain: string; walletData: WalletData; + stateStoragePath: string; messageHash: Buffer; derivationPath: string; password?: string; @@ -23,54 +26,76 @@ export async function sign(args: { logMessageWaiting?: string; logMessageCompleted?: string; }): Promise> { - const { host, chain, walletData, messageHash, derivationPath, password, id, logMessageWaiting, logMessageCompleted } = args; + const { host, chain, walletData, stateStoragePath, messageHash, derivationPath, password, id, logMessageWaiting, logMessageCompleted } = args; + const storedSessionPath = path.join(stateStoragePath, id); const transformISignature = (signature: TssSign.ISignature): string => { return Transactions.transformSignatureObject({ chain, obj: signature }); }; + const storeSession = (session: string) => { + const encrypted = JSON.stringify(Encryption.encryptWithPassword(session, password)); + fs.writeFileSync(storedSessionPath, encrypted, 'utf8'); + }; + const tssSign = new TssSign.TssSign({ baseUrl: url.resolve(host, '/bws/api'), credentials: walletData.credentials, tssKey: walletData.key as TssKeyType }); - try { - await tssSign.start({ - id, - messageHash, - derivationPath, - password - }); - } catch (err) { - if (err.message?.startsWith('TSS_ROUND_ALREADY_DONE')) { - const sig = await tssSign.getSignatureFromServer(); - if (!sig) { - throw new Error('It looks like the TSS signature session was interrupted. Try deleting this proposal and creating a new one.'); + // Restore a previously-interrupted TSS session if it exists + if (fs.existsSync(storedSessionPath)) { + const storedSession = Encryption.decryptWithPassword(fs.readFileSync(storedSessionPath, 'utf8'), password); + await tssSign.restoreSession({ session: storedSession.toString(), password }); + + // ...otherwise, start a new TSS session + } else { + try { + await tssSign.start({ + id, + messageHash, + derivationPath, + password + }); + storeSession(tssSign.exportSession()); + } catch (err) { + if (err.message?.startsWith('TSS_ROUND_ALREADY_DONE')) { + const sig = await tssSign.getSignatureFromServer(); + if (!sig) { + throw new Error('It looks like the TSS signature session was interrupted. Try deleting this proposal and creating a new one.'); + } + return { + signature: transformISignature(sig), + publicKey: sig.pubKey + }; } - return { - signature: transformISignature(sig), - publicKey: sig.pubKey - }; + throw err; } - throw err; } - const spinner = prompt.spinner({ indicator: 'timer' }); + + const spinner = prompt.spinner({ indicator: 'timer', onCancel: () => { tssSign.unsubscribe(); } }); spinner.start(logMessageWaiting || 'Waiting for all parties to join...'); const sig = await new Promise>((resolve, reject) => { - process.on('SIGINT', () => { - tssSign.unsubscribe(); - spinner.stop('Cancelled by user'); - reject(new UserCancelled()); - }); - tssSign.subscribe(); - tssSign.on('roundsubmitted', (round) => spinner.message(`Round ${round} submitted`)); - tssSign.on('error', e => prompt.log.error('Unexpected error during TSS signing: ' + (e.stack || e))); + tssSign.on('roundsubmitted', (round) => { + storeSession(tssSign.exportSession()); + spinner.message(`Round ${round} submitted`); + }); + tssSign.on('error', e => { + if (e instanceof Errors.NOT_AUTHORIZED && e.message === 'Session not found') { + tssSign.unsubscribe({ clearEvents: true }); + spinner.cancel('TSS session not found. It may have been deleted by another party.'); + return reject(new ProcessCancelled()); + } + prompt.log.error('Unexpected error during TSS signing: ' + (e.stack || e)); + }); tssSign.on('complete', async () => { try { spinner.stop(logMessageCompleted || 'TSS signature generated'); + // Clean up the stored session file after successful signing + fs.rmSync(storedSessionPath, { force: true }); const signature: TssSign.ISignature = tssSign.getSignature(); const sigString = transformISignature(signature); resolve({ @@ -81,6 +106,9 @@ export async function sign(args: { reject(err); } }); + tssSign.on('unsubscribe', () => { + reject(new UserCancelled()); + }); }); return sig; diff --git a/packages/bitcore-cli/src/wallet.ts b/packages/bitcore-cli/src/wallet.ts index 2dd5592d86f..f6867d973db 100644 --- a/packages/bitcore-cli/src/wallet.ts +++ b/packages/bitcore-cli/src/wallet.ts @@ -592,6 +592,8 @@ export class Wallet implements IWallet { throw new Error('TSS wallets do not yet support Solana.'); } + const stateStoragePath = await this.storage.getStatePath(); + const sigs: string[] = []; const inputPaths = !isUtxo && !txp.inputPaths?.length ? ['m/0/0'] : txp.inputPaths; @@ -616,6 +618,7 @@ export class Wallet implements IWallet { host: this.host, chain: txp.chain, walletData: this.#walletData, + stateStoragePath, messageHash: Buffer.from(messageHash, 'hex'), derivationPath, password, @@ -691,10 +694,13 @@ export class Wallet implements IWallet { throw new Error('TSS signing is only supported for TSS wallets.'); } + const stateStoragePath = await this.storage.getStatePath(); + const sig = await tssSign({ host: this.host, chain: this.client.credentials.chain, walletData: this.#walletData, + stateStoragePath, messageHash, derivationPath, password diff --git a/packages/bitcore-wallet-client/src/lib/common/encryption.ts b/packages/bitcore-wallet-client/src/lib/common/encryption.ts index 335e085919a..a30ce570a28 100644 --- a/packages/bitcore-wallet-client/src/lib/common/encryption.ts +++ b/packages/bitcore-wallet-client/src/lib/common/encryption.ts @@ -94,7 +94,7 @@ class EncryptionClass { }; } - _baseDecrypt(data: string | IEncrypted, key: Buffer) { + _baseDecrypt(data: string | IEncrypted, key: Buffer): Buffer { const json: IEncrypted = typeof data === 'string' ? JSON.parse(data) : data; const ct = Buffer.from(json.ct, 'base64'); const authTagLength = json.ts / 8; @@ -124,7 +124,7 @@ class EncryptionClass { return decrypted; } - decryptWithKey(data: string | IEncrypted, key: string | Buffer) { + decryptWithKey(data: string | IEncrypted, key: string | Buffer): Buffer { try { const keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key, 'base64'); return this._baseDecrypt(data, keyBuffer); @@ -138,7 +138,7 @@ class EncryptionClass { } } - decryptWithPassword(data: string | IEncrypted, password: string) { + decryptWithPassword(data: string | IEncrypted, password: string): Buffer { try { const json = typeof data === 'string' ? JSON.parse(data) : data; const key = crypto.pbkdf2Sync(password, Buffer.from(json.salt, 'base64'), json.iter, json.ks / 8, 'sha256'); diff --git a/packages/bitcore-wallet-client/src/lib/credentials.ts b/packages/bitcore-wallet-client/src/lib/credentials.ts index b499efdf800..6d0fb5b9341 100644 --- a/packages/bitcore-wallet-client/src/lib/credentials.ts +++ b/packages/bitcore-wallet-client/src/lib/credentials.ts @@ -390,6 +390,7 @@ export class Credentials { (!this.publicKeyRing || this.publicKeyRing.length != this.n) ) return false; + if (this.tssKeyId && (!this.publicKeyRing || this.publicKeyRing.length <= 1)) return false; // need at least 2 participants for TSS return true; } } diff --git a/packages/bitcore-wallet-client/src/lib/tsskey.ts b/packages/bitcore-wallet-client/src/lib/tsskey.ts index 8c6bea0d654..5861d26ce07 100644 --- a/packages/bitcore-wallet-client/src/lib/tsskey.ts +++ b/packages/bitcore-wallet-client/src/lib/tsskey.ts @@ -610,6 +610,7 @@ export class TssKeyGen extends EventEmitter { /** * Unsubscribe from the TSS key generation process + * Calling this method will emit the 'unsubscribe' event. */ unsubscribe(params: { /** @@ -625,6 +626,7 @@ export class TssKeyGen extends EventEmitter { } this.#subscriptionId = null; this.#subscriptionRunning = false; + this.emit('unsubscribe'); } /** diff --git a/packages/bitcore-wallet-client/src/lib/tsssign.ts b/packages/bitcore-wallet-client/src/lib/tsssign.ts index 984fbb5ffcc..27eb30c7d2a 100644 --- a/packages/bitcore-wallet-client/src/lib/tsssign.ts +++ b/packages/bitcore-wallet-client/src/lib/tsssign.ts @@ -162,13 +162,24 @@ export class TssSign extends EventEmitter { * Session string to restore */ session: string; + /** + * Password to decrypt the TSS private key share. + * Only needed if + * a) you're restoring a cold session (i.e. instantiating a new TssSig instance), and + * b) the TSS private key share is encrypted + */ + password?: string; }): Promise { - const { session } = params; - const [id, sigSession] = session.split(':'); + const { session, password } = params; + $.checkArgument(password || this.#tssKey.keychain.privateKeyShare, 'password is required to decrypt the TSS private key share'); + + const parts = session.split(':'); + const id = parts.slice(0, -1).join(':'); + const sigSession = parts[parts.length - 1]; this.id = id; this.#sign = await ECDSA.Sign.restore({ session: sigSession, - keychain: this.#tssKey.keychain, + keychain: this.#tssKey.get(password).keychain, authKey: this.#credentials.requestPrivKey }); return this; @@ -266,9 +277,8 @@ export class TssSign extends EventEmitter { } /** - * Unsubscribe from the TSS key generation process - * @param {object} [params] - * @param {boolean} [params.clearEvents] Whether to remove all event listeners (default: true) + * Unsubscribe from the TSS key generation process. + * Calling this method will emit the 'unsubscribe' event. */ unsubscribe(params: { /** @@ -285,6 +295,7 @@ export class TssSign extends EventEmitter { this.#subscriptionId = null; this.#subscriptionRunning = false; this.#emittedParticipants = null; + this.emit('unsubscribe'); } /** diff --git a/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts b/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts index 19317fed086..847e136440f 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts @@ -38,7 +38,7 @@ export function authTssRequest(): express.RequestHandler { } else if (req.path.includes('/tss/sign/')) { session = await storage.fetchTssSigSession({ id }); partyId = session?.participants.find(p => p.copayerId === copayerId)?.partyId; - pubKey = partyId == null ? null : session.rounds[0].find(r => r.fromPartyId === partyId).messages.publicKey; + pubKey = partyId == null ? null : session?.rounds[0]?.find(r => r.fromPartyId === partyId)?.messages.publicKey; } if (!session) { diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index d64b5beff64..32331c3b1d4 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -3249,7 +3249,7 @@ export class WalletService implements IWalletService { { txProposalId: opts.txProposalId }, - (err, txp) => { + (err, txp: TxProposal) => { if (err) return cb(err); if (!txp.isPending()) return cb(Errors.TX_NOT_PENDING); @@ -3257,7 +3257,15 @@ export class WalletService implements IWalletService { const deleteLockTime = this.getRemainingDeleteLockTime(txp); if (deleteLockTime > 0) return cb(Errors.TX_CANNOT_REMOVE); - this.storage.removeTx(this.walletId, txp.id, () => { + this.storage.removeTx(this.walletId, txp.id, async () => { + try { + if (Array.isArray(txp.inputPaths)) { + const inputPaths = txp.inputPaths.length ? txp.inputPaths : ['m/0/0']; // doesn't actually matter what's in the array + await Promise.all(inputPaths.map((_, i) => this.storage.removeTssSigSession({ id: `${txp.id}:input${i}` }))); + } + } catch (err) { + logger.warn('Error removing tss sig session for wallet %s txp %s: %o', this.walletId, txp.id, err); + } this._notifyTxProposalAction('TxProposalRemoved', txp, cb); }); } diff --git a/packages/bitcore-wallet-service/src/lib/storage.ts b/packages/bitcore-wallet-service/src/lib/storage.ts index 8e10d308a69..92a547d7cd8 100644 --- a/packages/bitcore-wallet-service/src/lib/storage.ts +++ b/packages/bitcore-wallet-service/src/lib/storage.ts @@ -1920,4 +1920,8 @@ export class Storage { ); } + async removeTssSigSession({ id }: { id: string }) { + return this.db.collection(collections.TSS_SIGN).deleteOne({ id }, { w: 1 }); + } + }