From 78bcb1e81b38ae4fbc1f0b837feff8cb2a2ba9e3 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Wed, 12 Aug 2026 15:26:56 -0400 Subject: [PATCH] add tss versioning & expiration --- .../src/lib/common/constants.ts | 5 +- .../bitcore-wallet-client/src/lib/tsskey.ts | 9 +- .../bitcore-wallet-client/src/lib/tsssign.ts | 7 +- .../test/data/initialKeyGenState.json | 87 +++++ .../bitcore-wallet-client/test/tss.test.ts | 356 +++++++++++++++++- .../src/lib/common/constants.ts | 7 +- .../src/lib/common/defaults.ts | 13 +- .../src/lib/errors/errordefinitions.ts | 6 + .../src/lib/model/tsskeygen.ts | 24 +- .../src/lib/model/tsssign.ts | 22 +- .../lib/routes/middleware/authTssRequest.ts | 10 +- .../lib/routes/middleware/verifyTssMessage.ts | 21 +- .../src/lib/routes/tss.ts | 15 +- .../bitcore-wallet-service/src/lib/tss.ts | 224 +++++++++-- 14 files changed, 730 insertions(+), 76 deletions(-) create mode 100644 packages/bitcore-wallet-client/test/data/initialKeyGenState.json diff --git a/packages/bitcore-wallet-client/src/lib/common/constants.ts b/packages/bitcore-wallet-client/src/lib/common/constants.ts index 8771fa2ab08..6b55ec3e321 100644 --- a/packages/bitcore-wallet-client/src/lib/common/constants.ts +++ b/packages/bitcore-wallet-client/src/lib/common/constants.ts @@ -66,5 +66,8 @@ export const Constants = { arb: 'arb', base: 'base', op: 'op', - } + }, + + TSS_KEYGEN_VERSION: 1.1, + TSS_SIGN_VERSION: 1.1 }; diff --git a/packages/bitcore-wallet-client/src/lib/tsskey.ts b/packages/bitcore-wallet-client/src/lib/tsskey.ts index f3cd65d329c..56702ba546b 100644 --- a/packages/bitcore-wallet-client/src/lib/tsskey.ts +++ b/packages/bitcore-wallet-client/src/lib/tsskey.ts @@ -2,13 +2,14 @@ import { EventEmitter } from 'events'; import { ECDSA, ECIES } from '@bitpay-labs/bitcore-tss'; import { BitcoreLib } from '@bitpay-labs/crypto-wallet-core'; import { API as Client, CreateWalletOpts } from './api'; -import { Encryption } from './common'; +import { Constants, Encryption } from './common'; import { Credentials } from './credentials'; import { ExportedKey, Key, KeyAlgorithm, PasswordMaybe } from './key'; import { Request, RequestResponse } from './request'; const $ = BitcoreLib.util.preconditions; +const { TSS_KEYGEN_VERSION } = Constants; export interface ITssKeyGenConstructorParams { /** @@ -274,7 +275,7 @@ export class TssKeyGen extends EventEmitter { this.partyId = 0; const msg = await keygen.initJoin(); - await this.#request.post('/v1/tss/keygen/' + this.id, { message: msg, n, password }); + await this.#request.post('/v1/tss/keygen/' + this.id, { message: msg, n, password, version: TSS_KEYGEN_VERSION }); this.#keygen = keygen; return this; } @@ -415,7 +416,7 @@ export class TssKeyGen extends EventEmitter { const msg = await keygen.initJoin(); password = password || extra; - await this.#request.post('/v1/tss/keygen/' + this.id, { message: msg, password }); + await this.#request.post('/v1/tss/keygen/' + this.id, { message: msg, password, version: TSS_KEYGEN_VERSION }); return this; } @@ -532,7 +533,7 @@ export class TssKeyGen extends EventEmitter { if (!this.#keygen.isKeyChainReady()) { // For 2 P2P messages (i.e. party of 3), it already exceeds 100 KB (190 KB) // Assuming ~80KB per message, the max server size of 2MB would be ~25 P2P messages - await this.#request.post(`/v1/tss/keygen/${this.id}`, { message: msg }); + await this.#request.post(`/v1/tss/keygen/${this.id}`, { message: msg, version: TSS_KEYGEN_VERSION }); this.emit('roundsubmitted', thisRound); } } catch (err) { diff --git a/packages/bitcore-wallet-client/src/lib/tsssign.ts b/packages/bitcore-wallet-client/src/lib/tsssign.ts index ed7520a5bab..2ea926d0956 100644 --- a/packages/bitcore-wallet-client/src/lib/tsssign.ts +++ b/packages/bitcore-wallet-client/src/lib/tsssign.ts @@ -1,12 +1,15 @@ import { EventEmitter } from 'events'; import { ECDSA } from '@bitpay-labs/bitcore-tss'; import { BitcoreLib } from '@bitpay-labs/crypto-wallet-core'; +import { Constants } from './common'; import { Credentials } from './credentials'; import { Request, RequestResponse } from './request'; import { type TssExportedKey, TssKey } from './tsskey'; const $ = BitcoreLib.util.preconditions; +const { TSS_SIGN_VERSION } = Constants; + export interface ISignature { r: string; s: string; @@ -137,7 +140,7 @@ export class TssSign extends EventEmitter { const msg = await this.#sign.initJoin(); const m = this.#tssKey.metadata.m; - await this.#request.post('/v1/tss/sign/' + this.id, { message: msg, m }); + await this.#request.post('/v1/tss/sign/' + this.id, { message: msg, m, version: TSS_SIGN_VERSION }); this.#emittedParticipants = new Set([this.#credentials.copayerId]); this.emit('copayerReady', this.#credentials.copayerId); return this; @@ -248,7 +251,7 @@ export class TssSign extends EventEmitter { if (!this.#sign.isSignatureReady() && !body.signature) { // For 2 P2P messages (i.e. party of 3), it already exceeds 100 KB (190 KB) // Assuming ~80KB per message, the max server size of 2MB would be ~25 P2P messages - await this.#request.post(`/v1/tss/sign/${this.id}`, { message: msg }); + await this.#request.post(`/v1/tss/sign/${this.id}`, { message: msg, version: TSS_SIGN_VERSION }); this.emit('roundsubmitted', thisRound); } } catch (err) { diff --git a/packages/bitcore-wallet-client/test/data/initialKeyGenState.json b/packages/bitcore-wallet-client/test/data/initialKeyGenState.json new file mode 100644 index 00000000000..a8c2e4c1923 --- /dev/null +++ b/packages/bitcore-wallet-client/test/data/initialKeyGenState.json @@ -0,0 +1,87 @@ +{ + "party0Mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "party1Mnemonic": "proof proof proof proof proof proof proof proof proof proof proof proof", + "party2Mnemonic": "rich rich rich rich rich rich rich rich rich rich rich ride", + "party0Session": "0cda0b3ba7e2271272ddf6a62c64c7555228eb49eedc0a02e429bae57622fc8e:0:2:3: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", + "party1Session": "0cda0b3ba7e2271272ddf6a62c64c7555228eb49eedc0a02e429bae57622fc8e:1:2:3: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", + "party2Session": "0cda0b3ba7e2271272ddf6a62c64c7555228eb49eedc0a02e429bae57622fc8e:2:2:3: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", + "keygenModel": { + "id": "0cda0b3ba7e2271272ddf6a62c64c7555228eb49eedc0a02e429bae57622fc8e", + "n": 3, + "participants": [ + "2800988eb224fced360487b010fb4c552b69bb4d6a244c28657a7049d5bb38bc", + "2804669f9ebd03139d1cd2762696800cdf5503cc78b91009edea0113365eb4eb", + "1c7dcbadb415bdec1f5ebc63d6cdb385b11c3d1a2be879e199c87e7e2289ec8e" + ], + "rounds": [ + [ + { + "fromPartyId": 0, + "messages": { + "round": 0, + "partyId": 0, + "publicKey": "0267d4eadb5ad46a7a06c11c9085670b91a85e6a91cad50ff6084a9f287f40b235", + "p2pMessages": [], + "broadcastMessages": [ + { + "from": 0, + "payload": { + "message": "pGdmcm9tX2lkAGpzZXNzaW9uX2lkmCAYdRiXGIcYlhggGKcYqxhcGDkY8RiHGNUY3BieGO0YGxhMEBgvGMgYxhhoGNgY/xicGKkYvBhUGIYYbwUYPWpjb21taXRtZW50mCAY1RhOGCsYfhjIGNIYyRiFGIUYHRiSGDEYMRgbGNsYPBjFGPEYYRjgGKAY8BjtGCgYfBgyGJkYshgiGEcIAGN4X2mYIBhOGP8Yjhj8GMoYOBiSGMMYXRhPGH4YRxhVGIYYNBh7GMkYpgsY4hhjGL4YKhikGEAYxBjrGKkYYBj8GFkYfQ==", + "signature": "3044022036a54a21953bcafac11f4fbaaad6f58e0f1a9e704af491f7504d8f1d94f8e93d02206b52ac145b36ccb79062cca93709150b0f7331899d57fca0f0a241d94170348c" + } + } + ] + } + }, + { + "fromPartyId": 1, + "messages": { + "round": 0, + "partyId": 1, + "publicKey": "02b1496366b64a9ce9179a4d7f8de866017d9a966bd8bc3e6610b8dd7a55fbe934", + "p2pMessages": [], + "broadcastMessages": [ + { + "from": 1, + "payload": { + "message": "pGdmcm9tX2lkAWpzZXNzaW9uX2lkmCAYnREYUhgaGIYY4hioGP4YzhhBEhhjGHgYbhiUGHgY8BhBGJoYfQsYGBicGLkYnhgeGDIYqBjtGEoYQRigamNvbW1pdG1lbnSYIBgqGHMY5xh5GIoYvBiVGLsY/g0YHRhLGNsYfhicGFAYdBioGCYYzxj/GFEYQxjaGOgYpRh7GO4YRhhWGHgYZmN4X2mYIBivEhj9DxhUGGgYYxh0GNQYvRgaGFEYOhgdGE8YrxgkGIgY+xgaGN4YkhQYQxjxGNAYfhjqGEIJGO0Y6A==", + "signature": "30440220594acc3b1e0238373326fe25fefaa5cd311ea6aceeb22c0273738f9d77f6f26f0220153db9cd1996e3bc3e7292c10a504922308b646f9c4c31e94c865c1c7f2abf10" + } + } + ] + } + }, + { + "fromPartyId": 2, + "messages": { + "round": 0, + "partyId": 2, + "publicKey": "02e274cff63169c712cd87dfece90cb6ed620c47fb5a142637c611dfe53b5f322c", + "p2pMessages": [], + "broadcastMessages": [ + { + "from": 2, + "payload": { + "message": "pGdmcm9tX2lkAmpzZXNzaW9uX2lkmCAYGhjxGBgYwxiZGD4YixhbGG8Y4RizGCcYKRjxGH4YGRghGJYYghi6GEkYRBjwGDAYnBiRGDEYYhirGJsY0hjjamNvbW1pdG1lbnSYIBjpGOQYZBhLGJsYhhgeGPUYGRiPGN4YghYYdhjSGK8Yjhi8GLIGGJEYZRjtGPkFGOQEGIcYbxheGKIYSGN4X2mYIBgzGM8YWhgfEhhJGP8YNhhgGEQYqxhgGIYYrRgnGM8YGhjTGMcYUxj6GOEYxhh5GCAYuxgyGEMY3BhhGGsYKQ==", + "signature": "304402205acf9abdcf68a6114a19d24d581484b9b1e2910b927aa638ce6036b6c8b5d6b60220427314495667537112d9006ac60b342e64582c58a29de5d6a1f69ea6ab288f7e" + } + } + ] + } + } + ] + ], + "sharedPublicKey": null, + "schemeVersion": 1.1, + "joinPassword": null, + "keyShares": [ + null, + null, + null + ], + "createdOn": 1786406733467, + "timeLimit": 1200000, + "bwsJoinSecret": null, + "__v": 2 + } +} \ No newline at end of file diff --git a/packages/bitcore-wallet-client/test/tss.test.ts b/packages/bitcore-wallet-client/test/tss.test.ts index ef6d2a37963..b615664bfd2 100644 --- a/packages/bitcore-wallet-client/test/tss.test.ts +++ b/packages/bitcore-wallet-client/test/tss.test.ts @@ -13,7 +13,7 @@ import { BitcoreLib, Deriver } from '@bitpay-labs/crypto-wallet-core'; import { TssKeyGen, TssKey } from '../src/lib/tsskey'; import { TssSign } from '../src/lib/tsssign'; import log from '../src/lib/log'; -import Client from '../src'; +import Client, { type Credentials } from '../src'; import { helpers, blockchainExplorerMock @@ -24,7 +24,7 @@ const datadir = path.join(__dirname, 'data'); const Key = Client.Key; describe('TSS', function() { - this.timeout(10000); + this.timeout(Math.max(this['_timeout'], 10000)); const happyPath = testName => `\u263A HAPPY PATH - ${testName}`; @@ -81,11 +81,11 @@ describe('TSS', function() { const party0Key = new Key({ seedType: 'new' }); const party1Key = new Key({ seedType: 'new' }); const party2Key = new Key({ seedType: 'new' }); - let tss0; - let tss1; - let tss2; - let joinCode1; - let joinCode2; + let tss0: TssKeyGen; + let tss1: TssKeyGen; + let tss2: TssKeyGen; + let joinCode1: string; + let joinCode2: string; afterEach(function() { tss0?.unsubscribe(); @@ -213,6 +213,11 @@ describe('TSS', function() { }); it(happyPath('should start round 1 by party1'), async function() { + const e0 = tss0.exportSession(); + const e1 = tss1.exportSession(); + const e2 = tss2.exportSession(); + const sesh = await storage.fetchTssKeyGenSession({ id: tss0.id }); + // I chose to start the round with party 1. In practice, anyone can start the round const response = new Promise(r => tss1.once('roundsubmitted', r)); tss1.on('error', (e) => { should.not.exist(e?.message ?? e); }); @@ -285,9 +290,9 @@ describe('TSS', function() { it(happyPath('should do round 2 (with API fault tolerance)'), async function() { // fault tolerance setup const postStub = sandbox.stub(Request.prototype, 'post').throws(new Error('restore me')); - sandbox.spy(tss0, 'restoreSession'); - sandbox.spy(tss1, 'restoreSession'); - sandbox.spy(tss2, 'restoreSession'); + const t0RestoreSessionSpy = sandbox.spy(tss0, 'restoreSession'); + const t1RestoreSessionSpy = sandbox.spy(tss1, 'restoreSession'); + const t2RestoreSessionSpy = sandbox.spy(tss2, 'restoreSession'); function restore() { postStub.restore?.(); }; const response0 = new Promise(r => tss0.once('roundsubmitted', r)); @@ -306,7 +311,7 @@ describe('TSS', function() { const submitted2Round = await response2; submitted2Round.should.equal(2); // check that the fault tolerance worked - (tss0.restoreSession.callCount + tss1.restoreSession.callCount + tss2.restoreSession.callCount).should.be.gte(1); + (t0RestoreSessionSpy.callCount + t1RestoreSessionSpy.callCount + t2RestoreSessionSpy.callCount).should.be.gte(1); }); it(happyPath('should do round 3'), async function() { @@ -396,14 +401,14 @@ describe('TSS', function() { }); it('should cleanly handle a subscription to a finished session', async function() { - sandbox.spy(tss0, 'emit'); + const tss0EmitSpy = sandbox.spy(tss0, 'emit'); const complete = new Promise(r => tss0.once('complete', r)); tss0.on('error', (e) => { should.not.exist(e?.message ?? e); }); tss0.subscribe({ timeout: 10, iterHandler: () => tss0.unsubscribe() }); await complete; - tss0.emit.args.filter(o => o[0] === 'roundready').length.should.equal(0); - tss0.emit.args.filter(o => o[0] === 'tsskey').length.should.equal(1); - tss0.emit.args.filter(o => o[0] === 'complete').length.should.equal(1); + tss0EmitSpy.args.filter(o => o[0] === 'roundready').length.should.equal(0); + tss0EmitSpy.args.filter(o => o[0] === 'tsskey').length.should.equal(1); + tss0EmitSpy.args.filter(o => o[0] === 'complete').length.should.equal(1); }); // Keeping for documentation purposes @@ -995,4 +1000,325 @@ describe('TSS', function() { }); }); }); + + describe('Client Versions', function() { + // Intercepts Request.prototype.post and overrides the `version` field. + // Pass `undefined` to omit version entirely (simulates pre-v1.1 legacy clients). + function stubPostVersion(version: number | undefined) { + const origPost = Request.prototype.post; + sandbox.stub(Request.prototype, 'post').callsFake(function(url, body: any, cb) { + if (version !== undefined) { + return origPost.call(this, url, { ...body, version }, cb); + } else { + const { version: _v, ...rest } = body; + return origPost.call(this, url, rest, cb); + } + }); + } + + describe('Key Generation', function() { + it('should reject a too-old client version', async function() { + stubPostVersion(0.9); + const key = new Key({ seedType: 'new' }); + const tss = new TssKeyGen({ chain, network, baseUrl: '/bws/api', request: request(app), key }); + try { + await tss.newKey({ m, n }); + throw new Error('Should have thrown'); + } catch (err) { + err.name.should.include('UPGRADE_NEEDED'); + } + }); + + it('should reject a too-new client version', async function() { + stubPostVersion(2.0); + const key = new Key({ seedType: 'new' }); + const tss = new TssKeyGen({ chain, network, baseUrl: '/bws/api', request: request(app), key }); + try { + await tss.newKey({ m, n }); + throw new Error('Should have thrown'); + } catch (err) { + err.name.should.include('UPGRADE_NEEDED'); + err.message.should.include('TSS version too new'); + } + }); + + it('should accept a legacy client that omits the version field', async function() { + // Pre-v1.1 clients did not send the version; the server defaults to 1.0 + stubPostVersion(undefined); + const key = new Key({ seedType: 'new' }); + const tss = new TssKeyGen({ chain, network, baseUrl: '/bws/api', request: request(app), key }); + const result = await tss.newKey({ m, n }); + should.exist(result); + result.should.equal(tss); + }); + + it('should reject a version mismatch when joining a session', async function() { + // Party 0 creates with current version (1.1); party 1 tries to join with old version (1.0) + const party0Key = new Key({ seedType: 'new' }); + const party1Key = new Key({ seedType: 'new' }); + const tss0 = new TssKeyGen({ chain, network, baseUrl: '/bws/api', request: request(app), key: party0Key }); + await tss0.newKey({ m, n }); // real version, creates session with schemeVersion 1.1 + + const joinCode = tss0.createJoinCode({ + partyId: 1, + partyPubKey: party1Key.createCredentials(null, { network, n: 1, account: 0 }).requestPubKey + }); + + stubPostVersion(1.0); + const tss1 = new TssKeyGen({ chain, network, baseUrl: '/bws/api', request: request(app), key: party1Key }); + try { + await tss1.joinKey({ code: joinCode }); + throw new Error('Should have thrown'); + } catch (err) { + err.message.should.include('TSS_MISMATCH_VERSION'); + } + }); + }); + + describe('Signing', function() { + let vParty0TssKey: TssKey; + let vParty1TssKey: TssKey; + let vParty0Creds: Credentials; + let vParty1Creds: Credentials; + const vMessageHash = BitcoreLib.crypto.Hash.sha256(Buffer.from('client-version-test')); + const vDerivPath = 'm/0/0'; + + function objToBuf(_key, value) { + if (value && value.type === 'Buffer' && Array.isArray(value.data)) { + return Buffer.from(value.data); + } + return value; + } + + before(async function() { + ({ tss: vParty0TssKey } = JSON.parse(fs.readFileSync(`${datadir}/tss-party0.json`).toString(), objToBuf)); + ({ tss: vParty1TssKey } = JSON.parse(fs.readFileSync(`${datadir}/tss-party1.json`).toString(), objToBuf)); + vParty0TssKey = new TssKey(vParty0TssKey); + vParty1TssKey = new TssKey(vParty1TssKey); + vParty0Creds = vParty0TssKey.createCredentials(null, { chain, network: 'testnet', account: 0 }); + vParty1Creds = vParty1TssKey.createCredentials(null, { chain, network: 'testnet', account: 0 }); + + // Wallets for these keys were already created in the Signing suite's before() + // The below is in case of testing a .only run of this suite. + const session = await storage.fetchTssKeyGenSession({ id: vParty0TssKey.metadata.id }); + if (!session) { + await storage.storeTssKeyGenSession({ + doc: { + id: vParty0TssKey.metadata.id, + participants: [ + vParty0Creds.copayerId, + vParty1Creds.copayerId + ], + sharedPublicKey: vParty0TssKey.keychain.commonKeyChain, + } + }); + + const client = helpers.newClient(app); + for (const tssKey of [vParty0TssKey, vParty1TssKey]) { + await helpers.createAndJoinWallet( + [client, client, client], + [tssKey], + 1, + 1, + { + key: tssKey, + coin: chain.toLowerCase(), + tssKeyId: tssKey.metadata.id + } + ); + } + } + }); + + it('should reject a too-old client version', async function() { + stubPostVersion(0.9); + const sig = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: vParty0Creds, tssKey: vParty0TssKey }); + try { + await sig.start({ id: 'version-old-sign', messageHash: vMessageHash, derivationPath: vDerivPath }); + throw new Error('Should have thrown'); + } catch (err) { + err.name.should.include('UPGRADE_NEEDED'); + } + }); + + it('should reject a too-new client version', async function() { + stubPostVersion(2.0); + const sig = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: vParty0Creds, tssKey: vParty0TssKey }); + try { + await sig.start({ id: 'version-new-sign', messageHash: vMessageHash, derivationPath: vDerivPath }); + throw new Error('Should have thrown'); + } catch (err) { + err.name.should.include('UPGRADE_NEEDED'); + err.message.should.include('TSS version too new'); + } + }); + + it('should accept a legacy client that omits the version field', async function() { + // Pre-v1.1 clients did not send the version; the server defaults to 1.0 + stubPostVersion(undefined); + const sig0 = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: vParty0Creds, tssKey: vParty0TssKey }); + const sig1 = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: vParty1Creds, tssKey: vParty1TssKey }); + await sig0.start({ id: 'version-legacy-sign', messageHash: vMessageHash, derivationPath: vDerivPath }); + await sig1.start({ id: 'version-legacy-sign', messageHash: vMessageHash, derivationPath: vDerivPath }); + // No error: server treats missing version as 1.0, both sessions match + }); + + it('should reject a version mismatch when joining a signing session', async function() { + // Party 0 creates with current version (1.1); party 1 tries to join with old version (1.0) + const sig0 = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: vParty0Creds, tssKey: vParty0TssKey }); + await sig0.start({ id: 'version-mismatch-sign', messageHash: vMessageHash, derivationPath: vDerivPath }); + + stubPostVersion(1.0); + const sig1 = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: vParty1Creds, tssKey: vParty1TssKey }); + try { + await sig1.start({ id: 'version-mismatch-sign', messageHash: vMessageHash, derivationPath: vDerivPath }); + throw new Error('Should have thrown'); + } catch (err) { + err.message.should.include('TSS_MISMATCH_VERSION'); + } + }); + }); + }); + + describe('Session Expiration', function() { + // Stubs Date.now() past the server's 20-minute default time limit + function simulateExpiry() { + sandbox.stub(Date, 'now').returns(Date.now() + 25 * 60 * 1000); + } + + describe('Key Generation', function() { + let tss0: TssKeyGen; + let tss1: TssKeyGen; + let tss2: TssKeyGen; + + beforeEach(async function() { + }); + + it('should emit an error when subscribing to an expired keygen session', async function() { + const data = JSON.parse(fs.readFileSync(`${datadir}/initialKeyGenState.json`).toString()); + const key0 = new Key({ seedType: 'mnemonic', seedData: data.party0Mnemonic }); + const key1 = new Key({ seedType: 'mnemonic', seedData: data.party1Mnemonic }); + const key2 = new Key({ seedType: 'mnemonic', seedData: data.party2Mnemonic }); + tss0 = new TssKeyGen({ chain, network, baseUrl: '/bws/api', request: request(app), key: key0 }); + tss1 = new TssKeyGen({ chain, network, baseUrl: '/bws/api', request: request(app), key: key1 }); + tss2 = new TssKeyGen({ chain, network, baseUrl: '/bws/api', request: request(app), key: key2 }); + await tss0.restoreSession({ session: data.party0Session }); + await tss1.restoreSession({ session: data.party1Session }); + await tss2.restoreSession({ session: data.party2Session }); + await storage.storeTssKeyGenSession({ doc: data.keygenModel }); + + simulateExpiry(); + + const error0 = new Promise(r => tss0.once('error', (e) => { tss0.unsubscribe(); r(e); })); + tss0.subscribe({ timeout: 10, iterHandler: () => tss0.unsubscribe() }); + const error1 = new Promise(r => tss1.once('error', (e) => { tss1.unsubscribe(); r(e); })); + tss1.subscribe({ timeout: 10, iterHandler: () => tss1.unsubscribe() }); + const error2 = new Promise(r => tss2.once('error', (e) => { tss2.unsubscribe(); r(e); })); + tss2.subscribe({ timeout: 10, iterHandler: () => tss2.unsubscribe() }); + + const err0 = await error0; + err0.message.should.include('TSS_SESSION_EXPIRED'); + const err1 = await error1; + err1.message.should.include('TSS_SESSION_EXPIRED'); + const err2 = await error2; + err2.message.should.include('TSS_SESSION_EXPIRED'); + }); + + it('should not expire a session before the time limit is reached', async function() { + const key = new Key({ seedType: 'new' }); + const tss = new TssKeyGen({ chain, network, baseUrl: '/bws/api', request: request(app), key }); + await tss.newKey({ m, n }); + + // Advance time to just under the 20-minute limit (19 min) + sandbox.stub(Date, 'now').returns(Date.now() + 19 * 60 * 1000); + + // If the session were expired, an error event would fire and fail this test + tss.on('error', (e) => { should.not.exist(e?.message ?? e); }); + await new Promise(r => tss.subscribe({ timeout: 10, iterHandler: () => { tss.unsubscribe(); r(); } })); + }); + }); + + describe('Signing', function() { + let eParty0TssKey: TssKey; + let eParty1TssKey: TssKey; + let eParty0Creds; + let eParty1Creds; + const eMessageHash = BitcoreLib.crypto.Hash.sha256(Buffer.from('expiry-test')); + const eDerivPath = 'm/0/0'; + + function objToBuf(_key, value) { + if (value && value.type === 'Buffer' && Array.isArray(value.data)) { + return Buffer.from(value.data); + } + return value; + } + + before(async function() { + ({ tss: eParty0TssKey } = JSON.parse(fs.readFileSync(`${datadir}/tss-party0.json`).toString(), objToBuf)); + ({ tss: eParty1TssKey } = JSON.parse(fs.readFileSync(`${datadir}/tss-party1.json`).toString(), objToBuf)); + eParty0TssKey = new TssKey(eParty0TssKey); + eParty1TssKey = new TssKey(eParty1TssKey); + eParty0Creds = eParty0TssKey.createCredentials(null, { chain, network: 'testnet', account: 0 }); + eParty1Creds = eParty1TssKey.createCredentials(null, { chain, network: 'testnet', account: 0 }); + + // Wallets for these keys were already created in the Signing suite's before() + // The below is in case of testing a .only run of this suite. + const session = await storage.fetchTssKeyGenSession({ id: eParty0TssKey.metadata.id }); + if (!session) { + await storage.storeTssKeyGenSession({ + doc: { + id: eParty0TssKey.metadata.id, + participants: [eParty0Creds.copayerId, eParty1Creds.copayerId], + sharedPublicKey: eParty0TssKey.keychain.commonKeyChain, + } + }); + const client = helpers.newClient(app); + for (const tssKey of [eParty0TssKey, eParty1TssKey]) { + await helpers.createAndJoinWallet( + [client, client, client], + [tssKey], + 1, + 1, + { key: tssKey, coin: chain.toLowerCase(), tssKeyId: tssKey.metadata.id } + ); + } + } + }); + + it('should emit an error when subscribing to an expired signing session', async function() { + const sig0 = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: eParty0Creds, tssKey: eParty0TssKey }); + const sig1 = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: eParty1Creds, tssKey: eParty1TssKey }); + await sig0.start({ id: 'expiry-sign', messageHash: eMessageHash, derivationPath: eDerivPath }); + await sig1.start({ id: 'expiry-sign', messageHash: eMessageHash, derivationPath: eDerivPath }); + + simulateExpiry(); + + const error = new Promise(r => sig0.once('error', (e) => { sig0.unsubscribe(); r(e); })); + sig0.subscribe({ timeout: 10, iterHandler: () => sig0.unsubscribe() }); + const err = await error; + err.message.should.include('TSS_SESSION_EXPIRED'); + }); + + it('should not expire a signing session before the time limit is reached', async function() { + const sig0 = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: eParty0Creds, tssKey: eParty0TssKey }); + const sig1 = new TssSign({ baseUrl: '/bws/api', request: request(app), credentials: eParty1Creds, tssKey: eParty1TssKey }); + await sig0.start({ id: 'expiry-sign-valid', messageHash: eMessageHash, derivationPath: eDerivPath }); + await sig1.start({ id: 'expiry-sign-valid', messageHash: eMessageHash, derivationPath: eDerivPath }); + + // Advance time to just under the 20-minute limit (19 min) + sandbox.stub(Date, 'now').returns(Date.now() + 19 * 60 * 1000); + + const response0 = new Promise(r => sig0.once('roundsubmitted', r)); + const response1 = new Promise(r => sig1.once('roundsubmitted', r)); + sig0.on('error', (e) => { should.not.exist(e?.message ?? e); }); + sig1.on('error', (e) => { should.not.exist(e?.message ?? e); }); + sig0.subscribe({ timeout: 10, iterHandler: () => sig0.unsubscribe() }); + sig1.subscribe({ timeout: 10, iterHandler: () => sig1.unsubscribe() }); + const round0 = await response0; + const round1 = await response1; + round0.should.equal(1); + round1.should.equal(1); + }); + }); + }); }); \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/lib/common/constants.ts b/packages/bitcore-wallet-service/src/lib/common/constants.ts index 915d58c333b..1f99179ae33 100644 --- a/packages/bitcore-wallet-service/src/lib/common/constants.ts +++ b/packages/bitcore-wallet-service/src/lib/common/constants.ts @@ -226,6 +226,9 @@ export const Constants = { // May be different from the defaults if we rollback // or want to beta test new versions. - TSS_KEYGEN_SCHEME_VERSION_MAX: 1, - TSS_SIGGEN_SCHEME_VERSION_MAX: 1, + TSS_KEYGEN_SCHEME_VERSION_MAX: 1.1, + TSS_SIGGEN_SCHEME_VERSION_MAX: 1.1, + + TSS_KEYGEN_SCHEME_MIN_SERVER_VERSION: 1, + TSS_SIGGEN_SCHEME_MIN_SERVER_VERSION: 1, } as const; diff --git a/packages/bitcore-wallet-service/src/lib/common/defaults.ts b/packages/bitcore-wallet-service/src/lib/common/defaults.ts index 09134403f69..b625581067e 100644 --- a/packages/bitcore-wallet-service/src/lib/common/defaults.ts +++ b/packages/bitcore-wallet-service/src/lib/common/defaults.ts @@ -434,6 +434,15 @@ export const Defaults = { { code: 'NZD', name: 'New Zealand Dollar' } ], - TSS_KEYGEN_SCHEME_VERSION: 1, - TSS_SIGGEN_SCHEME_VERSION: 1, + // In general, the major version represents server upgrades or major security upgrades, and the minor version represents client upgrades. + // Example1: If the server has an API response overhaul, that's a breaking change for older clients - it should increment the major version. + // This will result in an UPGRADE_NEEDED error for older clients. + // Example2: If the client upgrades the encryption scheme, older clients will still be compatible with the server and + // other older clients - it should increment the minor version. Mismatching client versions will still break the process, but + // the difference from a major version bump is that matching older clients will still work. + TSS_KEYGEN_SCHEME_VERSION: 1.1, + TSS_SIGGEN_SCHEME_VERSION: 1.1, + + TSS_KEYGEN_TIME_LIMIT: 20 * 60 * 1000, // 20 minutes + TSS_SIGGEN_TIME_LIMIT: 20 * 60 * 1000, // 20 minutes } as const; diff --git a/packages/bitcore-wallet-service/src/lib/errors/errordefinitions.ts b/packages/bitcore-wallet-service/src/lib/errors/errordefinitions.ts index 43884042759..a458a8077f2 100644 --- a/packages/bitcore-wallet-service/src/lib/errors/errordefinitions.ts +++ b/packages/bitcore-wallet-service/src/lib/errors/errordefinitions.ts @@ -76,6 +76,9 @@ interface Errors { TSS_BWS_JOIN_SECRET_NOT_FOUND: T; TSS_NO_FINAL_SIGNATURE: T; TSS_INVALID_FINAL_SIGNATURE: T; + TSS_SESSION_EXPIRED: T; + TSS_MISMATCH_VERSION: T; + TSS_FINAL_SIGNATURE_MISMATCH: T; }; const errors: Errors = { @@ -148,6 +151,9 @@ const errors: Errors = { TSS_BWS_JOIN_SECRET_NOT_FOUND: 'BWS join secret not found', TSS_NO_FINAL_SIGNATURE: 'No signature provided', TSS_INVALID_FINAL_SIGNATURE: 'Invalid signature provided', + TSS_SESSION_EXPIRED: 'Session has expired', + TSS_MISMATCH_VERSION: 'TSS version does not match session version', + TSS_FINAL_SIGNATURE_MISMATCH: 'Final signature does not match the one generated by the session', }; const errorsObject = { codes: {} }; diff --git a/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts b/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts index 2b561d2aac9..4733e4c6c93 100644 --- a/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts +++ b/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts @@ -103,14 +103,30 @@ export class TssKeyGenModel implements ITssKeyGenModel { __v: number; - static create(params: { id: string; message: ITssKeyMessageObject; n: number; copayerId: string; passwordHash?: string }): TssKeyGenModel { - const { id, message, n, copayerId, passwordHash } = params; + /** + * Create a new TssKeyGenModel instance. This is used to create a new TSS keygen session. + */ + static create(params: { + /** Session ID */ + id: string; + /** Initial broadcast message from party 0 */ + message: ITssKeyMessageObject; + /** Number of key participants */ + n: number; + /** Copayer ID of party 0 */ + copayerId: string; + /** Password hash for joining the session */ + passwordHash?: string; + /** TSS keygen version */ + version: number; + }): TssKeyGenModel { + const { id, message, n, copayerId, passwordHash, version } = params; const { partyId } = message; $.checkArgument(partyId === 0, 'Key generation session must be started by partyId 0'); const x = new TssKeyGenModel(); x.id = id; - x.schemeVersion = Defaults.TSS_KEYGEN_SCHEME_VERSION; + x.schemeVersion = version || Defaults.TSS_KEYGEN_SCHEME_VERSION; x.n = n; x.participants = new Array(n); x.participants[partyId] = copayerId; @@ -121,7 +137,7 @@ export class TssKeyGenModel implements ITssKeyGenModel { x.joinPassword = passwordHash; x.keyShares = new Array(n); x.createdOn = Date.now(); - x.timeLimit = null; // TODO - add a session time limit + x.timeLimit = Defaults.TSS_KEYGEN_TIME_LIMIT; x.__v = 0; return x; } diff --git a/packages/bitcore-wallet-service/src/lib/model/tsssign.ts b/packages/bitcore-wallet-service/src/lib/model/tsssign.ts index a63c9dd6a2f..073dba5baf4 100644 --- a/packages/bitcore-wallet-service/src/lib/model/tsssign.ts +++ b/packages/bitcore-wallet-service/src/lib/model/tsssign.ts @@ -22,6 +22,8 @@ export interface ITssSigMessageObject { partyId: number; publicKey: string; round: number; + createdOn: number; + timeLimit?: number; signature?: { r: string; s: string; @@ -60,6 +62,14 @@ export interface ITssSigGenModel { fromPartyId: number; messages: ITssSigMessageObject; }>>; + /** + * Timestamp the session was created + */ + createdOn: number; + /** + * Session expires after this many milliseconds. + */ + timeLimit?: number; /** * The signature generated as the result of the TSS signature generation process. * The signature can be generated along a derivation path, which produces a pubKey that's @@ -90,16 +100,20 @@ export class TssSigGenModel implements ITssSigGenModel { }>>; signature?: ITssSigMessageObject['signature']; schemeVersion: number; + createdOn: number; + timeLimit?: number; __v: number; - static create(params: { id: string; message: ITssSigMessageObject; m: number; copayerId: string }): TssSigGenModel { - const { id, message, m, copayerId } = params; + static create(params: { id: string; message: ITssSigMessageObject; m: number; copayerId: string; version: number }): TssSigGenModel { + const { id, message, m, copayerId, version } = params; const { partyId } = message; const x = new TssSigGenModel(); x.id = id; - x.schemeVersion = Defaults.TSS_SIGGEN_SCHEME_VERSION; + x.schemeVersion = version || Defaults.TSS_SIGGEN_SCHEME_VERSION; + x.createdOn = Date.now(); + x.timeLimit = Defaults.TSS_SIGGEN_TIME_LIMIT; x.m = m; x.participants = [{ partyId, @@ -117,6 +131,8 @@ export class TssSigGenModel implements ITssSigGenModel { const x = new TssSigGenModel(); x.id = obj.id; x.schemeVersion = obj.schemeVersion; + x.createdOn = obj.createdOn; + x.timeLimit = obj.timeLimit; x.m = obj.m; x.participants = obj.participants; x.rounds = obj.rounds; 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 847e136440f..7bfe4fc8858 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts @@ -5,7 +5,8 @@ import { Errors } from '../../errors/errordefinitions'; import { WalletService, checkRequired } from '../../server'; import { error } from '../helpers'; import { getCredentials, getMessage } from './authRequest'; - +import type { TssKeyGenModel } from '../../model/tsskeygen'; +import type { TssSigGenModel } from '../../model/tsssign'; /** * Middleware to authenticate TSS requests and attach the TSS session to the request. @@ -13,7 +14,6 @@ import { getCredentials, getMessage } from './authRequest'; * and retrieves the TSS session based on the request path. */ export function authTssRequest(): express.RequestHandler { - return async function(req, res, next) { try { const storage = WalletService.getStorage(); @@ -28,7 +28,8 @@ export function authTssRequest(): express.RequestHandler { } = credentials; const { id } = req.params as { [key: string]: string }; - let session; + + let session: TssKeyGenModel | TssSigGenModel | null = null; let partyId = null; let pubKey = null; if (req.path.includes('/tss/keygen/')) { @@ -47,6 +48,9 @@ export function authTssRequest(): express.RequestHandler { if (!pubKey) { throw Errors.NOT_AUTHORIZED.withMessage('Copayer not found in session'); } + if (session.timeLimit && Date.now() > session.createdOn + session.timeLimit) { + throw Errors.TSS_SESSION_EXPIRED; + } const message = getMessage(req); const isValid = !!Utils.verifyMessage(message, signature, pubKey); diff --git a/packages/bitcore-wallet-service/src/lib/routes/middleware/verifyTssMessage.ts b/packages/bitcore-wallet-service/src/lib/routes/middleware/verifyTssMessage.ts index 9ae93ad1372..9f9ef6df368 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/middleware/verifyTssMessage.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/middleware/verifyTssMessage.ts @@ -1,7 +1,9 @@ import { utils as tssUtils } from '@bitpay-labs/bitcore-tss'; +import { ClientError } from '../../errors/clienterror'; import { Errors } from '../../errors/errordefinitions'; -import { ITssKeyMessageObject } from '../../model/tsskeygen'; -import { ITssSigMessageObject } from '../../model/tsssign'; +import { error } from '../helpers'; +import type { ITssKeyMessageObject } from '../../model/tsskeygen'; +import type { ITssSigMessageObject } from '../../model/tsssign'; export async function verifyTssMessage(req, res, next) { @@ -9,16 +11,16 @@ export async function verifyTssMessage(req, res, next) { const { message }: { message: ITssKeyMessageObject | ITssSigMessageObject } = req.body; const { publicKey } = message || {}; if (!publicKey) { - return res.status(400).send(Errors.TSS_PUBKEY_MISSING); + throw Errors.TSS_PUBKEY_MISSING; } if (!message.broadcastMessages?.length && !message.p2pMessages?.length) { - return res.status(400).send(Errors.TSS_INVALID_MESSAGE); + throw Errors.TSS_INVALID_MESSAGE; } for (const m of message.broadcastMessages) { if (!tssUtils.verifySignedData(m.payload, publicKey)) { - return res.status(400).send(Errors.TSS_INVALID_MESSAGE_SIG); + throw Errors.TSS_INVALID_MESSAGE_SIG; }; } for (const m of message.p2pMessages) { @@ -26,12 +28,15 @@ export async function verifyTssMessage(req, res, next) { // `m.payload.signature` is for the unencrypted message. // Only the recipients can verify on the client side. if (!m.payload || !m.payload.encryptedMessage || !m.payload.signature) { - return res.status(400).send(Errors.TSS_INVALID_MESSAGE_SIG); + throw Errors.TSS_INVALID_MESSAGE_SIG; } } return next(); - } catch { - return res.status(400).send(Errors.TSS_INVALID_MESSAGE); + } catch (err) { + if (err instanceof ClientError) { + return error.returnError(err, res, req); + } + return error.returnError(Errors.TSS_INVALID_MESSAGE, res, req); } }; diff --git a/packages/bitcore-wallet-service/src/lib/routes/tss.ts b/packages/bitcore-wallet-service/src/lib/routes/tss.ts index eef2beb954c..d1da9e82778 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/tss.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/tss.ts @@ -21,12 +21,15 @@ export class TssRouter { const { returnError, opts } = params; const router = express.Router(); + /** Key generation methods */ + router.post('/v1/tss/keygen/:id', createWalletLimiter(opts), verifyTssMessage, async function(req, res) { try { const id = req.params.id; - const { message, n, password } = req.body; + // version was not given by client until 1.1, so fallback to 1.0 + const { message, n, password, version = 1.0 } = req.body; const copayerId = req.headers['x-identity']; - await TssKeyGen.processMessage({ id, message, n, password, copayerId }); + await TssKeyGen.processMessage({ id, message, n, password, copayerId, version }); return res.send(); } catch (err) { return returnError(err ?? 'unknown', res, req); @@ -84,12 +87,16 @@ export class TssRouter { } }); + + /** Signature Methods */ + router.post('/v1/tss/sign/:id', authRequest(), verifyTssMessage, async function(req, res) { try { const id = req.params.id; - const { message, m } = req.body; + // version was not given by client until 1.1, so fallback to 1.0 + const { message, m, version = 1.0 } = req.body; const copayerId = req.headers['x-identity']; - await TssSign.processMessage({ id, message, m, copayerId }); + await TssSign.processMessage({ id, message, m, copayerId, version }); return res.send(); } catch (err) { return returnError(err ?? 'unknown', res, req); diff --git a/packages/bitcore-wallet-service/src/lib/tss.ts b/packages/bitcore-wallet-service/src/lib/tss.ts index 7934afbf824..0877cca01f6 100644 --- a/packages/bitcore-wallet-service/src/lib/tss.ts +++ b/packages/bitcore-wallet-service/src/lib/tss.ts @@ -1,4 +1,5 @@ import { BitcoreLib } from '@bitpay-labs/crypto-wallet-core'; +import { Constants } from './common/constants'; import { Errors } from './errors/errordefinitions'; import logger from './logger'; import { ITssKeyMessageObject, TssKeyGenModel } from './model/tsskeygen'; @@ -7,9 +8,16 @@ import { WalletService, checkRequired } from './server'; import { Storage } from './storage'; class TssKeyGenClass { + /** + * Get messages for a given party in a TSS keygen session. + * Only returns messages if all other parties have sent their messages for the round. + */ async getMessagesForParty(params: { + /** Session ID */ id: string; + /** Round number */ round: number; + /** Copayer ID of the requesting party */ copayerId: string; }): Promise<{ messages?: ITssKeyMessageObject[]; @@ -48,7 +56,23 @@ class TssKeyGenClass { return { messages, publicKey: session.sharedPublicKey, hasKeyBackup: !!session.keyShares?.[partyId] }; } - async processMessage(params: { id: string; message: ITssKeyMessageObject; n?: string | number; password?: string; copayerId: string }) { + /** + * Process a TSS keygen message. This will create a new session if the message is from party 0 and is for round 0. + */ + async processMessage(params: { + /** Session ID */ + id: string; + /** Submitted message to send to others */ + message: ITssKeyMessageObject; + /** Number of participants (only required for the initial message from party 0) */ + n?: string | number; + /** Password for the session */ + password?: string; + /** Copayer ID of the submitting party */ + copayerId: string; + /** TSS keygen version */ + version: number; + }): Promise { const { id, message, n, password, copayerId } = params; if (!id || typeof id !== 'string') { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid id provided: ' + id); @@ -57,6 +81,15 @@ class TssKeyGenClass { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid copayerId provided: ' + copayerId); } + // version was not given by client until 1.1, so fallback to 1.0 + const version = Number(params.version || 1.0); + if (version < Constants.TSS_SIGGEN_SCHEME_MIN_SERVER_VERSION) { + throw Errors.UPGRADE_NEEDED; + } + if (version > Constants.TSS_SIGGEN_SCHEME_VERSION_MAX) { + throw Errors.UPGRADE_NEEDED.withMessage('TSS version too new: ' + version); + } + const storage = WalletService.getStorage(); let session = await storage.fetchTssKeyGenSession({ id }); @@ -65,6 +98,11 @@ class TssKeyGenClass { throw Errors.TSS_INVALID_MESSAGE.withMessage('Invalid message provided'); } + if (session.schemeVersion != version) { + throw Errors.TSS_MISMATCH_VERSION.withMessage(`TSS version (${version}) does not match session version (${session.schemeVersion})`); + } + + if (!session.participants[message.partyId]) { if (!this._checkPassword({ session, password })) { throw Errors.TSS_INVALID_PASSWORD; @@ -83,13 +121,21 @@ class TssKeyGenClass { if (!this._isValidBroadcastMessage({ message })) { throw Errors.TSS_INVALID_MESSAGE.withMessage('Invalid broadcast message provided'); } - await this._initSession({ id, message, n, password, storage, copayerId }); + await this._initSession({ id, message, n, password, storage, copayerId, version }); } else { throw Errors.TSS_SESSION_NOT_FOUND; } } - private _checkPassword(params: { session: TssKeyGenModel; password: string }) { + /** + * Check the password for a TSS keygen session. Returns true if the password is valid or if no password is set for the session. + */ + private _checkPassword(params: { + /** TSS keygen session fetched from BWS storage */ + session: TssKeyGenModel; + /** Password to check */ + password: string; + }): boolean { const { session, password } = params; if (!session.joinPassword) { return true; @@ -101,6 +147,7 @@ class TssKeyGenClass { return session.joinPassword === passwordHash; } + /** Validate if the message is a valid broadcast message */ private _isValidBroadcastMessage(params: { message: ITssKeyMessageObject }) { const { message } = params; return typeof message?.broadcastMessages?.[0]?.from === 'number' && @@ -108,6 +155,7 @@ class TssKeyGenClass { typeof message?.broadcastMessages?.[0]?.payload?.signature === 'string'; } + /** Validate if the message is a valid P2P message */ private _isValidP2pMessage(params: { message: ITssKeyMessageObject }) { const { message } = params; return typeof message?.p2pMessages?.[0]?.from === 'number' && @@ -117,15 +165,24 @@ class TssKeyGenClass { typeof message?.p2pMessages?.[0]?.commitment === 'string'; } + /** Initialize a new TSS keygen session */ private async _initSession(params: { + /** Session ID */ id: string; + /** Broadcast message from party 0 */ message: ITssKeyMessageObject; + /** Number of participants */ n: number | string; + /** Password needed to join the session */ password?: string; + /** TSS keygen version */ + version: number; + /** BWS storage instance */ storage: Storage; + /** Copayer ID of the submitting party (party 0) */ copayerId: string; }) { - const { id, message, password, storage, copayerId } = params; + const { id, message, password, storage, copayerId, version } = params; const n = parseInt(params.n as string); if (!n || n < 1) { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid n provided: ' + n); @@ -141,7 +198,8 @@ class TssKeyGenClass { message, n, copayerId, - passwordHash + passwordHash, + version: version || 1.0 // Initial version of TSS keygen was 1.0, but BWC didn't pass it in }); const result = await storage.storeTssKeyGenSession({ doc }); if (!result.result.ok) { @@ -150,7 +208,20 @@ class TssKeyGenClass { } } - private async _pushMessage(params: { id: string; session: TssKeyGenModel; message: ITssKeyMessageObject; storage: Storage }) { + /** + * Push a TSS keygen message to the session. + * This will fail if the round is already complete or if the message is from a party that has already sent a message for the round. + */ + private async _pushMessage(params: { + /** Session ID */ + id: string; + /** TSS keygen session fetched from BWS storage */ + session: TssKeyGenModel; + /** Message to push to the session */ + message: ITssKeyMessageObject; + /** BWS storage instance */ + storage: Storage; + }) { const { id, session, message, storage } = params; const { round } = message; @@ -181,14 +252,15 @@ class TssKeyGenClass { } } + /** Save the generated public key to the TSS keygen session */ async storeKey(params: { id: string; message: { publicKey: ITssKeyMessageObject['publicKey']; - /** unused */ + /** Unused - removed support for saving keychain */ encryptedKeyChain: string; }; - /** unused - only needed for storing the encryptedKeyChain */ + /** Unused - only needed for storing the encryptedKeyChain */ copayerId: string; }) { const { id, message } = params; @@ -229,9 +301,13 @@ class TssKeyGenClass { // } } + /** Store the BWS join secret for the TSS keygen session */ async storeBwsJoinSecret(params: { + /** Session ID */ id: string; + /** BWS join secret */ secret: string; + /** Copayer ID of the submitting party (must be the session creator) */ copayerId: string; }) { const { id, secret, copayerId } = params; @@ -259,7 +335,13 @@ class TssKeyGenClass { } } - async getBwsJoinSecret(params: { id: string; copayerId: string }) { + /** Get the BWS join secret for the TSS keygen session */ + async getBwsJoinSecret(params: { + /** Session ID */ + id: string; + /** Copayer ID of the requesting party */ + copayerId: string; + }): Promise { const { id, copayerId } = params; const storage = WalletService.getStorage(); const session = await storage.fetchTssKeyGenSession({ id }); @@ -279,7 +361,18 @@ class TssKeyGenClass { export const TssKeyGen = new TssKeyGenClass(); class TssSignClass { - async getMessagesForParty(params: { id: string; round: number; copayerId: string }): Promise<{ messages?: ITssSigMessageObject[]; signature?: ITssSigMessageObject['signature']; participants?: string[] }> { + /** + * Get messages for a given party in a TSS signature session. + * Only returns messages if all other parties have sent their messages for the round. + */ + async getMessagesForParty(params: { + /** Session ID */ + id: string; + /** Round number */ + round: number; + /** Copayer ID of the requesting party */ + copayerId: string; + }): Promise<{ messages?: ITssSigMessageObject[]; signature?: ITssSigMessageObject['signature']; participants?: string[] }> { const { id, round, copayerId } = params; const storage = WalletService.getStorage(); @@ -312,7 +405,21 @@ class TssSignClass { return { participants }; } - async processMessage(params: { id: string; message: ITssSigMessageObject; m?: string | number; copayerId: string }) { + /** + * Process a TSS signature message. This will create a new session if the message is from party 0 and is for round 0. + */ + async processMessage(params: { + /** Session ID */ + id: string; + /** Submitted message to send to others */ + message: ITssSigMessageObject; + /** Number of signers (only required for the initial message from party 0) */ + m?: string | number; + /** Copayer ID of the sender */ + copayerId: string; + /** TSS sig generation version */ + version: number; + }) { const { id, message, m, copayerId } = params; if (!id || typeof id !== 'string') { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid id provided: ' + id); @@ -321,6 +428,15 @@ class TssSignClass { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid copayerId provided: ' + copayerId); } + // version was not given by client until 1.1, so fallback to 1.0 + const version = Number(params.version || 1.0); + if (version < Constants.TSS_SIGGEN_SCHEME_MIN_SERVER_VERSION) { + throw Errors.UPGRADE_NEEDED; + } + if (version > Constants.TSS_SIGGEN_SCHEME_VERSION_MAX) { + throw Errors.UPGRADE_NEEDED.withMessage('TSS version too new: ' + version); + } + const storage = WalletService.getStorage(); let session = await storage.fetchTssSigSession({ id }); @@ -328,6 +444,10 @@ class TssSignClass { if (!this._isValidBroadcastMessage({ message }) && !this._isValidP2pMessage({ message })) { throw Errors.TSS_INVALID_MESSAGE.withMessage('Invalid message provided'); } + + if (session.schemeVersion != version) { + throw Errors.TSS_MISMATCH_VERSION.withMessage(`TSS version (${version}) does not match session version (${session.schemeVersion})`); + } const isParticipant = session.participants.some(p => p.copayerId === copayerId && p.partyId === message.partyId); if (!isParticipant) { @@ -357,20 +477,22 @@ class TssSignClass { if (!this._isValidBroadcastMessage({ message })) { throw Errors.TSS_INVALID_MESSAGE.withMessage('Invalid broadcast message provided'); } - await this._initSession({ id, message, m, storage, copayerId }); + await this._initSession({ id, message, m, storage, copayerId, version }); } else { throw Errors.TSS_SESSION_NOT_FOUND; } } - private _isValidBroadcastMessage(params: { message: ITssSigMessageObject }) { + /** Checks if a broadcast message is in a valid format */ + private _isValidBroadcastMessage(params: { message: ITssSigMessageObject }): boolean { const { message } = params; return typeof message?.broadcastMessages?.[0]?.from === 'number' && typeof message?.broadcastMessages?.[0]?.payload?.message === 'string' && typeof message?.broadcastMessages?.[0]?.payload?.signature === 'string'; } - private _isValidP2pMessage(params: { message: ITssSigMessageObject }) { + /** Checks if a P2P message is in a valid format */ + private _isValidP2pMessage(params: { message: ITssSigMessageObject }): boolean { const { message } = params; return typeof message?.p2pMessages?.[0]?.from === 'number' && typeof message?.p2pMessages?.[0]?.to === 'number' && @@ -378,8 +500,24 @@ class TssSignClass { typeof message?.p2pMessages?.[0]?.payload?.signature === 'string'; } - private async _initSession(params: { id: string; message: ITssSigMessageObject; m: number | string; storage: Storage; copayerId: string }) { - const { id, message, storage, copayerId } = params; + /** + * Initialize a new TSS signature session. This is only called when the first message from party 0 for round 0 is received. + */ + private async _initSession(params: { + /** Session ID */ + id: string; + /** Initial broadcast message by party 0 */ + message: ITssSigMessageObject; + /** Number of signers */ + m: number | string; + /** BWS storage instance */ + storage: Storage; + /** Party 0's copayer ID */ + copayerId: string; + /** TSS sig generation version given by client to ensure compatibility with others */ + version: number; + }): Promise { + const { id, message, storage, copayerId, version } = params; const m = parseInt(params.m as string); if (!m || m < 1) { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid m provided: ' + m); @@ -388,7 +526,8 @@ class TssSignClass { id, message, m, - copayerId + copayerId, + version }); const result = await storage.storeTssSigSession({ doc }); if (!result.result.ok) { @@ -397,7 +536,19 @@ class TssSignClass { } } - private async _pushMessage(params: { id: string; session: TssSigGenModel; message: ITssSigMessageObject; storage: Storage }) { + /** + * Push a TSS signature message to the session. + */ + private async _pushMessage(params: { + /** Session ID */ + id: string; + /** TSS sig generation session fetched from BWS storage */ + session: TssSigGenModel; + /** TSS signature message to be pushed */ + message: ITssSigMessageObject; + /** BWS storage instance */ + storage: Storage; + }): Promise { const { id, session, message, storage } = params; const { round } = message; @@ -428,7 +579,15 @@ class TssSignClass { } } - async storeSignature(params: { id: string; signature: ITssSigMessageObject['signature'] }) { + /** + * Stores the signature to the session + */ + async storeSignature(params: { + /** Session ID */ + id: string; + /** Signature to store */ + signature: ITssSigMessageObject['signature']; + }): Promise { const { id, signature } = params; if (!signature) { throw Errors.TSS_NO_FINAL_SIGNATURE; @@ -443,15 +602,24 @@ class TssSignClass { throw Errors.TSS_SESSION_NOT_FOUND; } - const result = await storage.storeTssSignature({ id, signature: { - r: signature.r, - s: signature.s, - v: signature.v, - pubKey: signature.pubKey, - } }); - if (!result.result.ok) { - logger.error('Failed to store TSS signature %o %o', id, result); - throw Errors.TSS_GENERIC_ERROR.withMessage('Failed to store TSS signature'); + if (!session.signature) { + const result = await storage.storeTssSignature({ id, signature: { + r: signature.r, + s: signature.s, + v: signature.v, + pubKey: signature.pubKey, + } }); + if (!result.result.ok) { + logger.error('Failed to store TSS signature %o %o', id, result); + throw Errors.TSS_GENERIC_ERROR.withMessage('Failed to store TSS signature'); + } + } else if ( + session.signature.r !== signature.r || + session.signature.s !== signature.s || + session.signature.v !== signature.v || + session.signature.pubKey !== signature.pubKey + ) { + throw Errors.TSS_FINAL_SIGNATURE_MISMATCH; } } };