Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/bitcore-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 4 additions & 7 deletions packages/bitcore-cli/src/commands/create/createThresholdSig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve, reject) => {
process.on('SIGINT', () => {
tss.unsubscribe();
spinner.stop('Cancelled by user');
reject(new UserCancelled());
});

tss.subscribe({
walletName: wallet.name,
copayerName,
Expand Down Expand Up @@ -161,6 +155,9 @@ export async function createThresholdSigWallet(
reject(err);
}
});
tss.on('unsubscribe', () => {
reject(new UserCancelled());
});
});


Expand Down
5 changes: 5 additions & 0 deletions packages/bitcore-cli/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ export class UserCancelled extends Error {
}
};

export class ProcessCancelled extends Error {
constructor() {
super('Cancelled by process');
}
}
8 changes: 8 additions & 0 deletions packages/bitcore-cli/src/filestorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
};
86 changes: 57 additions & 29 deletions packages/bitcore-cli/src/tss.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,61 +18,84 @@ export async function sign(args: {
host: string;
chain: string;
walletData: WalletData;
stateStoragePath: string;
messageHash: Buffer;
derivationPath: string;
password?: string;
id?: string;
logMessageWaiting?: string;
logMessageCompleted?: string;
}): Promise<CWCTypes.Message.ISignedMessage<string>> {
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<CWCTypes.Message.ISignedMessage<string>>((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({
Expand All @@ -81,6 +106,9 @@ export async function sign(args: {
reject(err);
}
});
tssSign.on('unsubscribe', () => {
reject(new UserCancelled());
});
});

return sig;
Expand Down
6 changes: 6 additions & 0 deletions packages/bitcore-cli/src/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/bitcore-wallet-client/src/lib/common/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions packages/bitcore-wallet-client/src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
2 changes: 2 additions & 0 deletions packages/bitcore-wallet-client/src/lib/tsskey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
/**
Expand All @@ -625,6 +626,7 @@ export class TssKeyGen extends EventEmitter {
}
this.#subscriptionId = null;
this.#subscriptionRunning = false;
this.emit('unsubscribe');
}

/**
Expand Down
23 changes: 17 additions & 6 deletions packages/bitcore-wallet-client/src/lib/tsssign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TssSign> {
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;
Expand Down Expand Up @@ -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: {
/**
Expand All @@ -285,6 +295,7 @@ export class TssSign extends EventEmitter {
this.#subscriptionId = null;
this.#subscriptionRunning = false;
this.#emittedParticipants = null;
this.emit('unsubscribe');
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 10 additions & 2 deletions packages/bitcore-wallet-service/src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3249,15 +3249,23 @@ 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);

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);
});
}
Expand Down
4 changes: 4 additions & 0 deletions packages/bitcore-wallet-service/src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1920,4 +1920,8 @@ export class Storage {
);
}

async removeTssSigSession({ id }: { id: string }) {
return this.db.collection(collections.TSS_SIGN).deleteOne({ id }, { w: 1 });
}

}