From 78bcb1e81b38ae4fbc1f0b837feff8cb2a2ba9e3 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Wed, 12 Aug 2026 15:26:56 -0400 Subject: [PATCH 01/13] 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:RZ69BL0WVYsZD/ChN/1XjvcEHhR38kwx53+7ogCDAlV77tzwxK3wy4Y7FScRgf4W6/n0iuc5R+da9o78VPpBRbFSJW8xu36w2AAB2Q7D+v1k5ANdGwhW1FcqDwdgkhIHYuJXNBuzd+9IY/DhPjDzA25kjwnpXWAxxi7/+p65h4UgP1YADuI2UMffLWIYjQKDekYpf7wEAyDxmf0hUAl+SrCcmQRvsKkhQyQyq6VwwPWTBgN438UpT0hUiiMJPUETXcEuq6+lNVfKRWkKVJQGwGx5ryOnTGOwK6/xV/YlyHVMkn5e5pZDYnh4pjUR5JmMonZE6vtXowyJxGzpGqBHKH2wqGcThlikUXKmNn5KZC/oLOOtxnpECyvm9ZjoTvkVVEGa+z8ewafkmPfvCWt9mryu5B157vi4H9HVxpPEsiM+0oPNDM308rrTT6PZMma0qEHqOFhtvboFrSv8TNYh8U1qLi+Uo/I5Ex3hYcJPLdlx/38sf1XijduMb1m1EoJAV0FTpXmvupLltJCTNWObGXDK51yTe2GnRaZMmX464X9ZX48vQlHMKIUpzPzlOgyowJOZs5xlNMBI+jamWXpTFrY2BAkHTnUGNGZ0evauBSnpHEm0P2nNyt7pEqN9z4H1RMQgVgmfGVrSsQClGMMuupF/hR+V4KdssodqExbVs1UvyvV1WHwToPPAN6Q9gtkjD5zAsuWT/YQSCcLdXF1iDgnlk7PXl7gXrD60Fo4ePDq7o1mC7ab31mnV2aHCEHQCFTWQzOtcF0Ofzp4MUugts0xMizircUowDEWgmGmq6kpTE9vD7EsIv3FmOWswJzdxU+6xkB+JHnWltjl5xgF1WiZtKaLcH+E1pEjRRMG8GQdgdRZ/6+crHsPmERpHUIKZBHpDc8+nCSu8JSZaFKy3BXgPaUvS52MOgVaKmecscempceM/68doOstzjEhVV2YQLq3EWcwZzBNHHaORvdr4fIK9O1kTYakMEpyO09A29+0+lp35w4rDw2l1Vm+mYCOcYMVDk69WvycDkJ15t1qz4KIyPaKyPf+E1hPdLdS1RrTVJMUin5+w2rpKaGsLtdPrZdnO+9mXw+SaD70ItGh1J3W44iPtJEIXjh2vq2EsStFVIYY0+qC4zcJ+33s9i6XcsNS4/6VosL0NdxX0PP4OI4oPfRX6iJ1YrQPjtDG9EwwbgUJTjEVBKFtXiEv+owvogNgGwkzQCxF1rF9G6pm3SM/UDc+36eCFUZtxbgv2cRmWKdK2WAfn+JNxTK+DtfXeTWzdXoEU0HoN3lOfZ3CRxPQAqwKYm4CNI/p4o4ouZIFxksC90Qogw6SW2JQQnG3rmlFTXuYwjCTyxbLlPYtdXXNJIFicFKAZr1Shg8J0nSydtegYytSoaGtjECst1JPtfGLpQaK9kkdRLiSSj/XasRZzbvaI/mcdhjqM9QDHGRLoIanNdeXF1kEFFcS5lLCLJJMzX0I01mQzezz4AxKtc2NOuHRIkwqMG9HWqBGW7+cObHKTYJfg2f2wAXrAiXE+c4hgUwrObzzeWhCmdpumJi7OGYNDiBxx9R6RL8nQw7Z8ZHbAwghYxeG8gxvfZ8KL+LyYJH1d+lAMea8MOFdF4CD6kQa8QyhPFh0uUI7/9AMiw/vDsYEatD94yXdAG8v9l3efACiSAfP/DoOO58PnxUwLR7J4KfMvngtAfa7g/QFgdv1Tpu3LfbdsA640/EwsvkMMuz9dRyrhGPHiIe/BvM9RoVDtffMI+DaWmk4IgAiUqr2iRcmnrKCa/Yb0HSIxLjme6M2t8CkC8YSxkSY4L9c08HZiQaWKePiKGg0fOvQ/s6SN70SZZrreUJWjB/Ry3PanHsoYYsMIqWGAOEmmtAXFkuxE/fPOdnbcDkC3oLs7J81SfQteZ8R9YzZm+/aQqZ33NHH7HgkWOS7mFGOSu3MVRsXtXoKCaonzAKDMSH7WwE+zXYkntPmZOslvrjAyArH5nZ3NcUEqKV5N2Q8nzfwkfvBjRKvnV7/ob7k7JBRwXLOKVFNcvAIpkEQHaL5RRPMUdkbqcTEhkmUiho4QfsrKLMFOGDTl+Td1O8sPAS/Fi7IGXVhJzrrEB8N9D9PEkuVjwV9hB7/bk1ERiU1Ssswb0yb9EKtkp03N7sZFZJsblQtQWaKCEPpNBz2ww/fF0SJmnQbaWBWEVGsciPCx3R04YoprOGLmCdB6DTtLl+wEMH6aPAon76Ef77HsTEvrxpWLLm18rSM1emxPPF6T2S7TTjHvmZENoe7ZaUCOfPDj5H0+TmBTZ6tOuH5mPjqCh1LM/PmTYoEVWwT9utJJRw==", + "party1Session": "0cda0b3ba7e2271272ddf6a62c64c7555228eb49eedc0a02e429bae57622fc8e:1:2:3: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", + "party2Session": "0cda0b3ba7e2271272ddf6a62c64c7555228eb49eedc0a02e429bae57622fc8e:2:2:3:BAlPewSh0AKYmn0wgBHjYFVFk6AI3LuOWUmEvqPdQARxOX91707aOI8gvDtOrmG27qn4wXW5thgqjVQss7VLhGykwtmBhn6jf6J6Wiq3dhtzm3g/JtBw63YFWqtNY9BBWZTIvaL0B3SXapKaZulj95In9jFNEHh8KvcMPkJwgK0zdHP6zj5AB9Rh1exdXeGOI9riQS1VwuppPtYg3g1lmtZeZoFbK0UU9frUvxriRKHmnKZMkcFak0SGz6qIoLvTtHYQZqSqqHoHn/ddqtCrl5dFaq5AYFdJ3VvZe2NO7Yqao3EPQIQCbPPiHqc7UDaWBIJyBvdK3CbdW1xSHLLLBf7JQgbTzH+YdLA9s7q5GmRZco3z8IOa469bOmB9gV0mqvZapZVuEB8SYNk5ZJvpPUUw7Ih7REe6lbLPnY3tEQqHQRnbwFWZYLDf763wHRAr0ZvmPNtpt/RvNokw71gLTfHdftaUVxaIwUrKyZOozYfFZR6r9XELCfMfZsXR0+4cWWbBCJqXeqXNYQHRV7zAi9Ghd72eZl+XNL7H/HdqxEqMXKjzVohVJr8xaNR0OgGT6XsvTO1nkpk45VAcQWNsJj7FUBm8GxWahcbNqQY76l65EIlwla+7IWnNQymal+X4GxHDqMX0u2hPMS6Ur1ke0YHIq1JKR3swH3itIaxXEdW+JJBwrIkaZnDKv/SY2C0IExP1SosBfWAgUBZkaIlTbC+OkTvTmv6UIKnql25pLSAMN5yr0LP4WkWW0T/VVA6xdphwyjV7cJYqpz5q1MV10GDhnpQlr1pWbWGCm7MMuEQMrOJosGJHQeZysc1TLbgmEXaUGlVWa1Lhstm8mrwSKiCgbNZFyf1Qu6xeHrFaF9oGtFM/7wso0rvlKzzc3YMrqe8Bor/DQeJq/OuSdKm57cVu/FhB4lIGBRkGDoIXbxp05PpOLyXvLT8QEKew3KgunJOD1GirT/ogjJMpOU2hQoTkplLLONR9TsWtEW+0R58IK1grZWoH1bO0THcA+TfjQDBNcyfswaYQNd4p1wPE41up4QMxQW+QTmiYKs37c8C7fnvCPz+R/OhTZNF7LivpY3Yo9xymcfaFvTu5ak0o4z/P/H/MM+B3yebM8a3HrORCxF6FptYtBWN6Uqup4QJb9QShB8gH6V78PMtuk20AG4aQdKWGqZql96q/mhnlRvTq+3A6oheUbSShw3GvklJJ4L8AAin4J3hFa4V4E6WLurenNmy+4yfJNGsJGlBWZoGItV+DzDC1NxZHPdaDkNfQ/kbUe8XIpDXeWjIO7NwEh6BWdDTieOar7/MkXcnPysOEb86KEqxcvHyXn1gQAjDMLcsMOI4DlkTDtKGLXoxOJRyAacV5ZNnGE/QOZK9GDgAfGPjCV4H/3Zz3V3lIh6bsaHaAyVXH2grzIM4i/OLBkJ+8Fz/S3DG9Y9d9uLLsfAY3/u36PrzGeguI9GpzUOSIxhRjo3FngRz9Z3S/H9g4ZJOeGZax+XHQKPFxcK3QTo1224k2I8wB7MvdJmhHG+0xGl5WjpNYf1bvi+S/yZJX3AIDz/tdGF3xXXrHBxHcTNwymi97hza8owAiXGqCJczHBjV7+B2RUvVBKcbb5M9pKKVjpL0RvSCFX6JzET4pRadLYsCCojkD8+R3JpeElENoIm+ZMiRWumbpWpvv6qxG7p0H01UxDnGIU4Qob33MBRCmoLivaTgEFWshTDWslbn+BsfSO/700w7JneeUbiGIoWk/lbX6UdoYzwomlNclPuqc+L+TyLwhrI3JRtOLAuZnC2wG6u7Llyw2f4oVKZdX6gOLQ7S/EqLkYXt/QEjbyI3CNysJdL2Kd/PtL5S2kaU1zMNF5+2EyxLWqx85GFQ+HO1PBe2iQRFCBX8QHFPXCvFVlcbSR0BXiHVgSybyYJf2yCLKdsNPBV4esBW+Mz//TYC+LHrM0SQChbiBVtGVpNJtfBaekAu7VwvM+NvHI5vcs5fVCXjUrGsLQcI2DnX01TNLLSyOrEHgypmPFm8ICUpeKaPek5bDycft1aw4dDK8aw2VB2nbLyP4HVLHFL+O4nAPiqtpeNwZAI/aeE57ZdmmPK2yUMVvLcdN503Gkdxqo6LEaFW5vAAPqYRWEHk3HTBF/mIAVYNz3ekwsKg456+q8peG8YHEs0tY4rRgvhcDty/qKSdk6OF0Q6jQdBfD57S5xzS0EBydyciYOkAsRzPn2qadcXCpzVrPcoQUSp0Uj86aPgiiIww28zWH1pBWh2hdv7WmgOCOz5c9iZq20tOsBHFMTDuTMJUz08JZWIqT7aBlUuZX1Nj8tMYtwXaHHQ==", + "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; } } }; From a0b5098b469a216ea02a69bc63d9a4711ebbfe57 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Tue, 25 Aug 2026 11:14:58 -0400 Subject: [PATCH 02/13] fix tss tests --- packages/bitcore-wallet-client/test/tss.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/bitcore-wallet-client/test/tss.test.ts b/packages/bitcore-wallet-client/test/tss.test.ts index b615664bfd2..063d73a1837 100644 --- a/packages/bitcore-wallet-client/test/tss.test.ts +++ b/packages/bitcore-wallet-client/test/tss.test.ts @@ -1182,8 +1182,9 @@ describe('TSS', function() { 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); + function simulateExpiry(mins) { + const now = Date.now(); + sandbox.stub(Date, 'now').returns(now + mins * 60 * 1000); } describe('Key Generation', function() { @@ -1207,7 +1208,7 @@ describe('TSS', function() { await tss2.restoreSession({ session: data.party2Session }); await storage.storeTssKeyGenSession({ doc: data.keygenModel }); - simulateExpiry(); + simulateExpiry(25); // 25 minutes, past the 20-minute default limit const error0 = new Promise(r => tss0.once('error', (e) => { tss0.unsubscribe(); r(e); })); tss0.subscribe({ timeout: 10, iterHandler: () => tss0.unsubscribe() }); @@ -1230,7 +1231,7 @@ describe('TSS', function() { 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); + simulateExpiry(19); // If the session were expired, an error event would fire and fail this test tss.on('error', (e) => { should.not.exist(e?.message ?? e); }); @@ -1291,7 +1292,7 @@ describe('TSS', function() { await sig0.start({ id: 'expiry-sign', messageHash: eMessageHash, derivationPath: eDerivPath }); await sig1.start({ id: 'expiry-sign', messageHash: eMessageHash, derivationPath: eDerivPath }); - simulateExpiry(); + simulateExpiry(25); // 25 minutes, past the 20-minute default limit const error = new Promise(r => sig0.once('error', (e) => { sig0.unsubscribe(); r(e); })); sig0.subscribe({ timeout: 10, iterHandler: () => sig0.unsubscribe() }); @@ -1306,7 +1307,7 @@ describe('TSS', function() { 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); + simulateExpiry(19); const response0 = new Promise(r => sig0.once('roundsubmitted', r)); const response1 = new Promise(r => sig1.once('roundsubmitted', r)); From e4c2a50cf6ad72d577ed75b8610b2817a939673a Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Wed, 26 Aug 2026 13:08:03 -0400 Subject: [PATCH 03/13] add timeLImit as a configurable param; handle signature saving race condition; handle invalid version --- .../bitcore-wallet-client/test/tss.test.ts | 11 +- .../src/lib/common/defaults.ts | 4 +- .../src/lib/model/tsskeygen.ts | 6 +- .../src/lib/model/tsssign.ts | 13 +- .../src/lib/routes/tss.ts | 8 +- .../bitcore-wallet-service/src/lib/storage.ts | 2 +- .../bitcore-wallet-service/src/lib/tss.ts | 59 ++++-- .../test/integration/tss.test.ts | 186 ++++++++++++++++++ 8 files changed, 255 insertions(+), 34 deletions(-) diff --git a/packages/bitcore-wallet-client/test/tss.test.ts b/packages/bitcore-wallet-client/test/tss.test.ts index 063d73a1837..c4c666df941 100644 --- a/packages/bitcore-wallet-client/test/tss.test.ts +++ b/packages/bitcore-wallet-client/test/tss.test.ts @@ -3,6 +3,7 @@ import sinon from 'sinon'; import * as chai from 'chai'; import BWS from '@bitpay-labs/bitcore-wallet-service'; +import { Defaults as BwsDefaults } from '@bitpay-labs/bitcore-wallet-service/ts_build/src/lib/common/defaults'; import request from 'supertest'; import crypto from 'crypto'; import fs from 'fs'; @@ -1208,7 +1209,7 @@ describe('TSS', function() { await tss2.restoreSession({ session: data.party2Session }); await storage.storeTssKeyGenSession({ doc: data.keygenModel }); - simulateExpiry(25); // 25 minutes, past the 20-minute default limit + simulateExpiry(BwsDefaults.TSS_KEYGEN_TIME_LIMIT + 1); // 1 minute past the default limit const error0 = new Promise(r => tss0.once('error', (e) => { tss0.unsubscribe(); r(e); })); tss0.subscribe({ timeout: 10, iterHandler: () => tss0.unsubscribe() }); @@ -1230,8 +1231,7 @@ describe('TSS', function() { 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) - simulateExpiry(19); + simulateExpiry(BwsDefaults.TSS_KEYGEN_TIME_LIMIT - 1); // 1 minute before the default limit // If the session were expired, an error event would fire and fail this test tss.on('error', (e) => { should.not.exist(e?.message ?? e); }); @@ -1292,7 +1292,7 @@ describe('TSS', function() { await sig0.start({ id: 'expiry-sign', messageHash: eMessageHash, derivationPath: eDerivPath }); await sig1.start({ id: 'expiry-sign', messageHash: eMessageHash, derivationPath: eDerivPath }); - simulateExpiry(25); // 25 minutes, past the 20-minute default limit + simulateExpiry(BwsDefaults.TSS_SIGGEN_TIME_LIMIT + 1); // 1 minute past the default limit const error = new Promise(r => sig0.once('error', (e) => { sig0.unsubscribe(); r(e); })); sig0.subscribe({ timeout: 10, iterHandler: () => sig0.unsubscribe() }); @@ -1306,8 +1306,7 @@ describe('TSS', function() { 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) - simulateExpiry(19); + simulateExpiry(BwsDefaults.TSS_SIGGEN_TIME_LIMIT - 1); // 1 minute before the default limit const response0 = new Promise(r => sig0.once('roundsubmitted', r)); const response1 = new Promise(r => sig1.once('roundsubmitted', r)); diff --git a/packages/bitcore-wallet-service/src/lib/common/defaults.ts b/packages/bitcore-wallet-service/src/lib/common/defaults.ts index b625581067e..55afea2bfa3 100644 --- a/packages/bitcore-wallet-service/src/lib/common/defaults.ts +++ b/packages/bitcore-wallet-service/src/lib/common/defaults.ts @@ -443,6 +443,6 @@ export const Defaults = { 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 + TSS_KEYGEN_TIME_LIMIT: 20, // minutes + TSS_SIGGEN_TIME_LIMIT: 60 * 24, // minutes - 1 day (TODO tighten this up after testing) } as const; diff --git a/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts b/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts index 4733e4c6c93..c5953e88f7c 100644 --- a/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts +++ b/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts @@ -119,10 +119,14 @@ export class TssKeyGenModel implements ITssKeyGenModel { passwordHash?: string; /** TSS keygen version */ version: number; + /** Time limit for the session in minutes, after which the session expires */ + timeLimit?: number; }): TssKeyGenModel { const { id, message, n, copayerId, passwordHash, version } = params; const { partyId } = message; + const timeLimit = Number(params.timeLimit); $.checkArgument(partyId === 0, 'Key generation session must be started by partyId 0'); + $.checkArgument(!timeLimit || (Number.isFinite(timeLimit) && timeLimit > 0), 'Time limit must be a positive number'); const x = new TssKeyGenModel(); x.id = id; @@ -137,7 +141,7 @@ export class TssKeyGenModel implements ITssKeyGenModel { x.joinPassword = passwordHash; x.keyShares = new Array(n); x.createdOn = Date.now(); - x.timeLimit = Defaults.TSS_KEYGEN_TIME_LIMIT; + x.timeLimit = Math.min(timeLimit || Defaults.TSS_KEYGEN_TIME_LIMIT, 60) * 60 * 1000; // capped at 60 minutes 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 073dba5baf4..1fa5d104507 100644 --- a/packages/bitcore-wallet-service/src/lib/model/tsssign.ts +++ b/packages/bitcore-wallet-service/src/lib/model/tsssign.ts @@ -1,5 +1,8 @@ +import { singleton } from 'preconditions'; import { Common } from '../common'; +const $ = singleton(); + const { Defaults } = Common; @@ -22,8 +25,6 @@ export interface ITssSigMessageObject { partyId: number; publicKey: string; round: number; - createdOn: number; - timeLimit?: number; signature?: { r: string; s: string; @@ -105,15 +106,19 @@ export class TssSigGenModel implements ITssSigGenModel { __v: number; - static create(params: { id: string; message: ITssSigMessageObject; m: number; copayerId: string; version: number }): TssSigGenModel { + static create(params: { id: string; message: ITssSigMessageObject; m: number; copayerId: string; version: number; timeLimit?: number }): TssSigGenModel { const { id, message, m, copayerId, version } = params; const { partyId } = message; + const timeLimit = Number(params.timeLimit); + $.checkArgument(!timeLimit || (Number.isFinite(timeLimit) && timeLimit > 0), 'Time limit must be a positive number'); + + const MAX_TIME_LIMIT = 7 * 24 * 60; // 7 days in minutes const x = new TssSigGenModel(); x.id = id; x.schemeVersion = version || Defaults.TSS_SIGGEN_SCHEME_VERSION; x.createdOn = Date.now(); - x.timeLimit = Defaults.TSS_SIGGEN_TIME_LIMIT; + x.timeLimit = Math.min(timeLimit || Defaults.TSS_SIGGEN_TIME_LIMIT, MAX_TIME_LIMIT) * 60 * 1000; // capped at 7 days x.m = m; x.participants = [{ partyId, diff --git a/packages/bitcore-wallet-service/src/lib/routes/tss.ts b/packages/bitcore-wallet-service/src/lib/routes/tss.ts index d1da9e82778..b246a62fdf3 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/tss.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/tss.ts @@ -27,9 +27,9 @@ export class TssRouter { try { const id = req.params.id; // version was not given by client until 1.1, so fallback to 1.0 - const { message, n, password, version = 1.0 } = req.body; + const { message, n, password, version = 1.0, timeLimit } = req.body; const copayerId = req.headers['x-identity']; - await TssKeyGen.processMessage({ id, message, n, password, copayerId, version }); + await TssKeyGen.processMessage({ id, message, n, password, copayerId, version, timeLimit }); return res.send(); } catch (err) { return returnError(err ?? 'unknown', res, req); @@ -94,9 +94,9 @@ export class TssRouter { try { const id = req.params.id; // version was not given by client until 1.1, so fallback to 1.0 - const { message, m, version = 1.0 } = req.body; + const { message, m, version = 1.0, timeLimit } = req.body; const copayerId = req.headers['x-identity']; - await TssSign.processMessage({ id, message, m, copayerId, version }); + await TssSign.processMessage({ id, message, m, copayerId, version, timeLimit }); return res.send(); } catch (err) { return returnError(err ?? 'unknown', res, req); diff --git a/packages/bitcore-wallet-service/src/lib/storage.ts b/packages/bitcore-wallet-service/src/lib/storage.ts index 92a547d7cd8..7c8499ebf92 100644 --- a/packages/bitcore-wallet-service/src/lib/storage.ts +++ b/packages/bitcore-wallet-service/src/lib/storage.ts @@ -1910,7 +1910,7 @@ export class Storage { async storeTssSignature({ id, signature }: { id: string; signature: ITssSigMessageObject['signature'] }) { return this.db.collection(collections.TSS_SIGN).updateOne( - { id }, + { id, $or: [{ signature: null }, { signature: { $exists: false } }] }, { $set: { signature diff --git a/packages/bitcore-wallet-service/src/lib/tss.ts b/packages/bitcore-wallet-service/src/lib/tss.ts index 0877cca01f6..90b563c2283 100644 --- a/packages/bitcore-wallet-service/src/lib/tss.ts +++ b/packages/bitcore-wallet-service/src/lib/tss.ts @@ -72,8 +72,10 @@ class TssKeyGenClass { copayerId: string; /** TSS keygen version */ version: number; + /** Time limit for the session in minutes, after which the session expires. Only applies on session initialization */ + timeLimit?: number; }): Promise { - const { id, message, n, password, copayerId } = params; + const { id, message, n, password, copayerId, timeLimit } = params; if (!id || typeof id !== 'string') { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid id provided: ' + id); } @@ -82,7 +84,10 @@ class TssKeyGenClass { } // version was not given by client until 1.1, so fallback to 1.0 - const version = Number(params.version || 1.0); + const version = Number(params.version ?? 1.0); + if (!Number.isFinite(version)) { + throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid version provided: ' + params.version); + } if (version < Constants.TSS_SIGGEN_SCHEME_MIN_SERVER_VERSION) { throw Errors.UPGRADE_NEEDED; } @@ -121,7 +126,7 @@ 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, version }); + await this._initSession({ id, message, n, password, storage, copayerId, version, timeLimit }); } else { throw Errors.TSS_SESSION_NOT_FOUND; } @@ -181,8 +186,10 @@ class TssKeyGenClass { storage: Storage; /** Copayer ID of the submitting party (party 0) */ copayerId: string; + /** Time limit for the session in minutes, after which the session expires */ + timeLimit?: number; }) { - const { id, message, password, storage, copayerId, version } = params; + const { id, message, password, storage, copayerId, version, timeLimit } = params; const n = parseInt(params.n as string); if (!n || n < 1) { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid n provided: ' + n); @@ -199,7 +206,8 @@ class TssKeyGenClass { n, copayerId, passwordHash, - version: version || 1.0 // Initial version of TSS keygen was 1.0, but BWC didn't pass it in + version: version || 1.0, // Initial version of TSS keygen was 1.0, but BWC didn't pass it in + timeLimit }); const result = await storage.storeTssKeyGenSession({ doc }); if (!result.result.ok) { @@ -419,8 +427,10 @@ class TssSignClass { copayerId: string; /** TSS sig generation version */ version: number; + /** Time limit for the session in minutes, after which the session expires. Only applies on session initialization */ + timeLimit?: number; }) { - const { id, message, m, copayerId } = params; + const { id, message, m, copayerId, timeLimit } = params; if (!id || typeof id !== 'string') { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid id provided: ' + id); } @@ -429,7 +439,10 @@ class TssSignClass { } // version was not given by client until 1.1, so fallback to 1.0 - const version = Number(params.version || 1.0); + const version = Number(params.version ?? 1.0); + if (!Number.isFinite(version)) { + throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid version provided: ' + params.version); + } if (version < Constants.TSS_SIGGEN_SCHEME_MIN_SERVER_VERSION) { throw Errors.UPGRADE_NEEDED; } @@ -477,7 +490,7 @@ class TssSignClass { if (!this._isValidBroadcastMessage({ message })) { throw Errors.TSS_INVALID_MESSAGE.withMessage('Invalid broadcast message provided'); } - await this._initSession({ id, message, m, storage, copayerId, version }); + await this._initSession({ id, message, m, storage, copayerId, version, timeLimit }); } else { throw Errors.TSS_SESSION_NOT_FOUND; } @@ -516,8 +529,10 @@ class TssSignClass { copayerId: string; /** TSS sig generation version given by client to ensure compatibility with others */ version: number; + /** Time limit for the session in minutes, after which the session expires. Only applies on session initialization */ + timeLimit?: number; }): Promise { - const { id, message, storage, copayerId, version } = params; + const { id, message, storage, copayerId, version, timeLimit } = params; const m = parseInt(params.m as string); if (!m || m < 1) { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid m provided: ' + m); @@ -527,7 +542,8 @@ class TssSignClass { message, m, copayerId, - version + version, + timeLimit }); const result = await storage.storeTssSigSession({ doc }); if (!result.result.ok) { @@ -602,6 +618,13 @@ class TssSignClass { throw Errors.TSS_SESSION_NOT_FOUND; } + const verifySig = (sig1, sig2) => { + return sig1.r === sig2.r && + sig1.s === sig2.s && + sig1.v === sig2.v && + sig1.pubKey === sig2.pubKey; + }; + if (!session.signature) { const result = await storage.storeTssSignature({ id, signature: { r: signature.r, @@ -613,12 +636,16 @@ class TssSignClass { 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 - ) { + if (result.modifiedCount === 0) { + const updatedSesh = await storage.fetchTssSigSession({ id }); + if (!updatedSesh.signature) { + throw Errors.TSS_FINAL_SIGNATURE_MISMATCH.withMessage('Unable to fetch stored TSS signature'); + } + if (updatedSesh?.signature && !verifySig(updatedSesh.signature, signature)) { + throw Errors.TSS_FINAL_SIGNATURE_MISMATCH; + } + } + } else if (!verifySig(session.signature, signature)) { throw Errors.TSS_FINAL_SIGNATURE_MISMATCH; } } diff --git a/packages/bitcore-wallet-service/test/integration/tss.test.ts b/packages/bitcore-wallet-service/test/integration/tss.test.ts index 316c609481e..967bfc21afb 100644 --- a/packages/bitcore-wallet-service/test/integration/tss.test.ts +++ b/packages/bitcore-wallet-service/test/integration/tss.test.ts @@ -7,10 +7,15 @@ import * as twoOfThree from './data/tss/2of3'; import BWS from '../../src/index'; import helpers from './helpers'; import { TssKeyGen } from '../../src/lib/tss'; +import { TssSigGenModel } from '../../src/lib/model/tsssign'; +import { Common } from '../../src/lib/common/index'; const should = chai.should(); +const { Utils } = Common; describe('TSS', function() { + this.timeout(Math.max(this['_timeout'], 2000)); + const vector = { m: 2, n: 3, @@ -337,4 +342,185 @@ describe('TSS', function() { }); }); }); + + describe('POST /v1/tss/sign/:id/store', function() { + const url = id => `${urlPrefix}/v1/tss/sign/${id}/store`; + const signRequest = body => { + const message = `post|${url('test').replace(urlPrefix, '')}|${JSON.stringify(body)}`; + const hash = Utils.hashMessage(message, false); // BWC hashes the message with reversal, then signs + const xSignature = BitcoreLib.crypto.ECDSA.sign(hash, privateKey, { endian: 'little' }).toString(); + return xSignature; + }; + const privateKey = new BitcoreLib.PrivateKey('cd643c4f2e68b669d97c51877905e8d77efc98cabb5a36a8eda66bc143055d37'); + const publicKey = privateKey.publicKey.toString(); + const signature = { + r: '0x71a8fb6b23203f7ae7f5df7dac410204d8b8d8b74e31c3919e0c764a00925f74', + s: '0x18048c9ce4a860f6354b4bb795178ff3cde858ed3afef6c2e2c51c8fac8b9257', + v: 1, + pubKey: '0267c2c572fcc1a5026d0e5d8e60a04ff6adeaf1a3a996c9c69f3b1714cff1db85', + }; + + + beforeEach(async function() { + const storage = helpers.getStorage(); + await storage.db.collection(BWS.Storage.collections.TSS_SIGN).deleteMany({}); + const sigGen = TssSigGenModel.create({ + id: 'test', + message: { partyId: 0, publicKey } as any, + m: 2, + copayerId: publicKey, + version: 1.1, + }); + await storage.storeTssSigSession({ doc: sigGen }); + await storage.storeTssSignature({ id: 'test', signature }); + }); + + it('should throw a mismatch error for a mismatched signature: r', function(done) { + const modifiedSignature = { + ...signature, + r: '0x71a8fb6b23203f7ae7f5df7dac410204d8b8d8b74e31c3919e0c764a00925f75', // modified r value to create a mismatch + }; + const xSignature = signRequest({ signature: modifiedSignature }); + + app.post(url('test')) + .set('x-identity', publicKey) + .set('x-signature', xSignature) + .send({ + signature: modifiedSignature + }) + .expect(res => { + res.status.should.equal(400); + res.body.should.have.property('code'); + res.body.code.should.equal('TSS_FINAL_SIGNATURE_MISMATCH'); + res.body.message.should.equal('Final signature does not match the one generated by the session'); + }) + .end(done); + }); + + it('should throw a mismatch error for a mismatched signature: s', function(done) { + const modifiedSignature = { + ...signature, + s: '0x18048c9ce4a860f6354b4bb795178ff3cde858ed3afef6c2e2c51c8fac8b9256', // modified s value to create a mismatch + }; + const xSignature = signRequest({ signature: modifiedSignature }); + + app.post(url('test')) + .set('x-identity', publicKey) + .set('x-signature', xSignature) + .send({ + signature: modifiedSignature + }) + .expect(res => { + res.status.should.equal(400); + res.body.should.have.property('code'); + res.body.code.should.equal('TSS_FINAL_SIGNATURE_MISMATCH'); + res.body.message.should.equal('Final signature does not match the one generated by the session'); + }) + .end(done); + }); + + it('should throw a mismatch error for a mismatched signature: v', function(done) { + const modifiedSignature = { + ...signature, + v: 123, // modified v value to create a mismatch + }; + const xSignature = signRequest({ signature: modifiedSignature }); + + app.post(url('test')) + .set('x-identity', publicKey) + .set('x-signature', xSignature) + .send({ + signature: modifiedSignature + }) + .expect(res => { + res.status.should.equal(400); + res.body.should.have.property('code'); + res.body.code.should.equal('TSS_FINAL_SIGNATURE_MISMATCH'); + res.body.message.should.equal('Final signature does not match the one generated by the session'); + }) + .end(done); + }); + + it('should throw a mismatch error for a mismatched signature: pubKey', function(done) { + const modifiedSignature = { + ...signature, + pubKey: 'not-equal-pub-key', // modified pubKey value to create a mismatch + }; + const xSignature = signRequest({ signature: modifiedSignature }); + + app.post(url('test')) + .set('x-identity', publicKey) + .set('x-signature', xSignature) + .send({ + signature: modifiedSignature + }) + .expect(res => { + res.status.should.equal(400); + res.body.should.have.property('code'); + res.body.code.should.equal('TSS_FINAL_SIGNATURE_MISMATCH'); + res.body.message.should.equal('Final signature does not match the one generated by the session'); + }) + .end(done); + }); + + it('should cleanly handle race condition when saving signature', function(done) { + const fetchSessionStub = sandbox.stub(BWS.Storage.prototype, 'fetchTssSigSession'); + fetchSessionStub.callsFake(async function({ id }) { + const session = await fetchSessionStub.wrappedMethod.call(this, { id }); + session.signature = null; + return session; + }); + fetchSessionStub.onThirdCall().callsFake(async function({ id }) { + const session = await fetchSessionStub.wrappedMethod.call(this, { id }); + return session; + }); + const xSignature = signRequest({ signature }); + + app.post(url('test')) + .set('x-identity', publicKey) + .set('x-signature', xSignature) + .send({ + signature + }) + .expect(res => { + res.status.should.equal(200); + }) + .end(done); + }); + + it('should handle race condition when saving signature but mismatched signature', function(done) { + const fetchSessionStub = sandbox.stub(BWS.Storage.prototype, 'fetchTssSigSession'); + fetchSessionStub.callsFake(async function({ id }) { + const session = await fetchSessionStub.wrappedMethod.call(this, { id }); + session.signature = null; + return session; + }); + fetchSessionStub.onThirdCall().callsFake(async function({ id }) { + const session = await fetchSessionStub.wrappedMethod.call(this, { id }); + return session; + }); + + const modifiedSignature = { + ...signature, + r: '0x71a8fb6b23203f7ae7f5df7dac410204d8b8d8b74e31c3919e0c764a00925f75', // modified r value to create a mismatch + }; + + const xSignature = signRequest({ signature: modifiedSignature }); + + app.post(url('test')) + .set('x-identity', publicKey) + .set('x-signature', xSignature) + .send({ + signature: modifiedSignature + }) + .expect(res => { + res.status.should.equal(400); + res.body.should.have.property('code'); + res.body.code.should.equal('TSS_FINAL_SIGNATURE_MISMATCH'); + res.body.message.should.equal('Final signature does not match the one generated by the session'); + }) + .end(done); + }); + }); + }); \ No newline at end of file From d62ba77bd143dad7d4282e1ec82a138c8a143258 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Wed, 26 Aug 2026 14:33:30 -0400 Subject: [PATCH 04/13] add expiration checks & tests --- .../src/lib/model/tsskeygen.ts | 7 ++ .../src/lib/model/tsssign.ts | 7 ++ .../lib/routes/middleware/authTssRequest.ts | 2 +- .../bitcore-wallet-service/src/lib/tss.ts | 7 ++ .../test/integration/tss.test.ts | 89 +++++++++++++++++++ 5 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts b/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts index c5953e88f7c..fa28ec9fd83 100644 --- a/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts +++ b/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts @@ -171,4 +171,11 @@ export class TssKeyGenModel implements ITssKeyGenModel { } return mostRecentRound; } + + isExpired(): boolean { + if (!this.timeLimit) { + return false; + } + return Date.now() > this.createdOn + this.timeLimit; + } } diff --git a/packages/bitcore-wallet-service/src/lib/model/tsssign.ts b/packages/bitcore-wallet-service/src/lib/model/tsssign.ts index 1fa5d104507..55b57cf52d3 100644 --- a/packages/bitcore-wallet-service/src/lib/model/tsssign.ts +++ b/packages/bitcore-wallet-service/src/lib/model/tsssign.ts @@ -154,4 +154,11 @@ export class TssSigGenModel implements ITssSigGenModel { } return mostRecentRound; } + + isExpired(): boolean { + if (!this.timeLimit) { + return false; + } + return Date.now() > this.createdOn + this.timeLimit; + } } 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 7bfe4fc8858..832109aaed5 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts @@ -48,7 +48,7 @@ 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) { + if (session.isExpired()) { throw Errors.TSS_SESSION_EXPIRED; } diff --git a/packages/bitcore-wallet-service/src/lib/tss.ts b/packages/bitcore-wallet-service/src/lib/tss.ts index 90b563c2283..917677c3ac8 100644 --- a/packages/bitcore-wallet-service/src/lib/tss.ts +++ b/packages/bitcore-wallet-service/src/lib/tss.ts @@ -99,6 +99,10 @@ class TssKeyGenClass { let session = await storage.fetchTssKeyGenSession({ id }); if (session) { + if (session.isExpired()) { + throw Errors.TSS_SESSION_EXPIRED; + } + if (!this._isValidBroadcastMessage({ message }) && !this._isValidP2pMessage({ message })) { throw Errors.TSS_INVALID_MESSAGE.withMessage('Invalid message provided'); } @@ -454,6 +458,9 @@ class TssSignClass { let session = await storage.fetchTssSigSession({ id }); if (session) { + if (session.isExpired()) { + throw Errors.TSS_SESSION_EXPIRED; + } if (!this._isValidBroadcastMessage({ message }) && !this._isValidP2pMessage({ message })) { throw Errors.TSS_INVALID_MESSAGE.withMessage('Invalid message provided'); } diff --git a/packages/bitcore-wallet-service/test/integration/tss.test.ts b/packages/bitcore-wallet-service/test/integration/tss.test.ts index 967bfc21afb..395712a4de6 100644 --- a/packages/bitcore-wallet-service/test/integration/tss.test.ts +++ b/packages/bitcore-wallet-service/test/integration/tss.test.ts @@ -8,6 +8,7 @@ import BWS from '../../src/index'; import helpers from './helpers'; import { TssKeyGen } from '../../src/lib/tss'; import { TssSigGenModel } from '../../src/lib/model/tsssign'; +import { TssKeyGenModel } from '../../src/lib/model/tsskeygen'; import { Common } from '../../src/lib/common/index'; const should = chai.should(); @@ -255,6 +256,11 @@ describe('TSS', function() { describe('POST /v1/tss/keygen/:id', function() { const url = id => `${urlPrefix}/v1/tss/keygen/${id}`; + beforeEach(async function() { + const storage = helpers.getStorage(); + await storage.db.collection(BWS.Storage.collections.TSS_KEYGEN).deleteMany({}); + }); + describe('middleware', function() { beforeEach(function() { sandbox.stub(TssKeyGen, 'processMessage').resolves(); @@ -341,6 +347,89 @@ describe('TSS', function() { }); }); }); + + it('should error if session is expired', function(done) { + const storage = helpers.getStorage(); + const publicKey = vector.party0.authKey.publicKey.toString(); + const keyGen = TssKeyGenModel.create({ + id: 'test', + message: { partyId: 0, publicKey } as any, + n: 2, + copayerId: publicKey, + version: 1.1, + timeLimit: 1, // 1 minute + }); + storage.storeTssKeyGenSession({ doc: keyGen }).then(() => { + // advance time by 2 minutes + const now = Date.now() + 2 * 60 * 1000; + sandbox.stub(Date, 'now').returns(now); + + const message = JSON.parse(JSON.stringify(vector.keygen.messages.round1.party0)); + message.publicKey = publicKey; + + app.post(url('test')) + .set('x-identity', publicKey) + .send({ message }) + .expect((res) => { + res.status.should.equal(400); + res.body.code.should.equal('TSS_SESSION_EXPIRED'); + }) + .end(done); + }).catch(done); + }); + }); + + describe('POST /v1/tss/sign/:id', function() { + const url = id => `${urlPrefix}/v1/tss/sign/${id}`; + const signRequest = body => { + const message = `post|${url('test').replace(urlPrefix, '')}|${JSON.stringify(body)}`; + const hash = Utils.hashMessage(message, false); // BWC hashes the message with reversal, then signs + const xSignature = BitcoreLib.crypto.ECDSA.sign(hash, privateKey, { endian: 'little' }).toString(); + return xSignature; + }; + const privateKey = new BitcoreLib.PrivateKey('cd643c4f2e68b669d97c51877905e8d77efc98cabb5a36a8eda66bc143055d37'); + const publicKey = privateKey.publicKey.toString(); + + beforeEach(async function() { + const storage = helpers.getStorage(); + await storage.db.collection(BWS.Storage.collections.TSS_SIGN).deleteMany({}); + await storage.db.collection(BWS.Storage.collections.COPAYERS_LOOKUP).deleteMany({}); + await storage.db.collection(BWS.Storage.collections.COPAYERS_LOOKUP).insertOne({ + copayerId: publicKey, + walletId: 'test', + requestPubKey: publicKey, + }); + }); + + it('should error if session is expired', function(done) { + const storage = helpers.getStorage(); + const sigGen = TssSigGenModel.create({ + id: 'test', + message: { partyId: 0, publicKey } as any, + m: 2, + copayerId: publicKey, + version: 1.1, + timeLimit: 1, // 1 minute + }); + storage.storeTssSigSession({ doc: sigGen }).then(() => { + // advance time by 2 minutes + const now = Date.now() + 2 * 60 * 1000; + sandbox.stub(Date, 'now').returns(now); + + const message = { publicKey, p2pMessages: [{ payload: { encryptedMessage: 'encryptie', signature: 'siggie' } }], broadcastMessages: [] }; + const xSignature = signRequest({ message }); + + app.post(url('test')) + .set('x-identity', publicKey) + .set('x-signature', xSignature) + .send({ message }) + .expect((res) => { + res.status.should.equal(400); + res.body.code.should.equal('TSS_SESSION_EXPIRED'); + }) + .end(done); + }).catch(done); + }); }); describe('POST /v1/tss/sign/:id/store', function() { From 3f7c54dec7d01eaec801c67e93d92ed7f3582844 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Wed, 26 Aug 2026 15:46:46 -0400 Subject: [PATCH 05/13] better timeLimit parse --- packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts | 6 ++++-- packages/bitcore-wallet-service/src/lib/model/tsssign.ts | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts b/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts index fa28ec9fd83..11f917b3a5e 100644 --- a/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts +++ b/packages/bitcore-wallet-service/src/lib/model/tsskeygen.ts @@ -124,10 +124,12 @@ export class TssKeyGenModel implements ITssKeyGenModel { }): TssKeyGenModel { const { id, message, n, copayerId, passwordHash, version } = params; const { partyId } = message; - const timeLimit = Number(params.timeLimit); + const timeLimit = parseFloat(params.timeLimit?.toString()); $.checkArgument(partyId === 0, 'Key generation session must be started by partyId 0'); $.checkArgument(!timeLimit || (Number.isFinite(timeLimit) && timeLimit > 0), 'Time limit must be a positive number'); + const ONE_MINUTE = 60 * 1000; + const x = new TssKeyGenModel(); x.id = id; x.schemeVersion = version || Defaults.TSS_KEYGEN_SCHEME_VERSION; @@ -141,7 +143,7 @@ export class TssKeyGenModel implements ITssKeyGenModel { x.joinPassword = passwordHash; x.keyShares = new Array(n); x.createdOn = Date.now(); - x.timeLimit = Math.min(timeLimit || Defaults.TSS_KEYGEN_TIME_LIMIT, 60) * 60 * 1000; // capped at 60 minutes + x.timeLimit = Math.min(timeLimit || Defaults.TSS_KEYGEN_TIME_LIMIT, 60) * ONE_MINUTE; // capped at 60 minutes 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 55b57cf52d3..28895cb55e7 100644 --- a/packages/bitcore-wallet-service/src/lib/model/tsssign.ts +++ b/packages/bitcore-wallet-service/src/lib/model/tsssign.ts @@ -109,16 +109,19 @@ export class TssSigGenModel implements ITssSigGenModel { static create(params: { id: string; message: ITssSigMessageObject; m: number; copayerId: string; version: number; timeLimit?: number }): TssSigGenModel { const { id, message, m, copayerId, version } = params; const { partyId } = message; - const timeLimit = Number(params.timeLimit); + const timeLimit = parseFloat(params.timeLimit?.toString()); $.checkArgument(!timeLimit || (Number.isFinite(timeLimit) && timeLimit > 0), 'Time limit must be a positive number'); const MAX_TIME_LIMIT = 7 * 24 * 60; // 7 days in minutes + const ONE_MINUTE = 60 * 1000; const x = new TssSigGenModel(); x.id = id; x.schemeVersion = version || Defaults.TSS_SIGGEN_SCHEME_VERSION; x.createdOn = Date.now(); - x.timeLimit = Math.min(timeLimit || Defaults.TSS_SIGGEN_TIME_LIMIT, MAX_TIME_LIMIT) * 60 * 1000; // capped at 7 days + x.timeLimit = (timeLimit === 0 + ? MAX_TIME_LIMIT + : Math.min(timeLimit || Defaults.TSS_SIGGEN_TIME_LIMIT, MAX_TIME_LIMIT)) * ONE_MINUTE; x.m = m; x.participants = [{ partyId, From 28cb713b5118a0482aa02f6786d9bcefcc2802b8 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Wed, 26 Aug 2026 16:00:37 -0400 Subject: [PATCH 06/13] fix keygen version constants --- packages/bitcore-wallet-service/src/lib/tss.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bitcore-wallet-service/src/lib/tss.ts b/packages/bitcore-wallet-service/src/lib/tss.ts index 917677c3ac8..b2e11feaa35 100644 --- a/packages/bitcore-wallet-service/src/lib/tss.ts +++ b/packages/bitcore-wallet-service/src/lib/tss.ts @@ -88,10 +88,10 @@ class TssKeyGenClass { if (!Number.isFinite(version)) { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid version provided: ' + params.version); } - if (version < Constants.TSS_SIGGEN_SCHEME_MIN_SERVER_VERSION) { + if (version < Constants.TSS_KEYGEN_SCHEME_MIN_SERVER_VERSION) { throw Errors.UPGRADE_NEEDED; } - if (version > Constants.TSS_SIGGEN_SCHEME_VERSION_MAX) { + if (version > Constants.TSS_KEYGEN_SCHEME_VERSION_MAX) { throw Errors.UPGRADE_NEEDED.withMessage('TSS version too new: ' + version); } From c39bc4df9bc9f3a73d7c8cd0dffa1de5fcafcbf8 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Thu, 27 Aug 2026 11:37:51 -0400 Subject: [PATCH 07/13] consistent tss version naming; tss auth path fallback error --- packages/bitcore-wallet-service/src/lib/common/constants.ts | 4 ++-- .../src/lib/routes/middleware/authTssRequest.ts | 2 ++ packages/bitcore-wallet-service/src/lib/tss.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/bitcore-wallet-service/src/lib/common/constants.ts b/packages/bitcore-wallet-service/src/lib/common/constants.ts index 1f99179ae33..f3319e1a4e4 100644 --- a/packages/bitcore-wallet-service/src/lib/common/constants.ts +++ b/packages/bitcore-wallet-service/src/lib/common/constants.ts @@ -229,6 +229,6 @@ export const Constants = { 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, + TSS_KEYGEN_SCHEME_VERSION_MIN: 1, + TSS_SIGGEN_SCHEME_VERSION_MIN: 1, } as const; 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 832109aaed5..9f791a63086 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts @@ -40,6 +40,8 @@ export function authTssRequest(): express.RequestHandler { 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; + } else { + throw Errors.NOT_AUTHORIZED.withMessage('Invalid TSS request path'); } if (!session) { diff --git a/packages/bitcore-wallet-service/src/lib/tss.ts b/packages/bitcore-wallet-service/src/lib/tss.ts index b2e11feaa35..5cde1216ed4 100644 --- a/packages/bitcore-wallet-service/src/lib/tss.ts +++ b/packages/bitcore-wallet-service/src/lib/tss.ts @@ -88,7 +88,7 @@ class TssKeyGenClass { if (!Number.isFinite(version)) { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid version provided: ' + params.version); } - if (version < Constants.TSS_KEYGEN_SCHEME_MIN_SERVER_VERSION) { + if (version < Constants.TSS_KEYGEN_SCHEME_VERSION_MIN) { throw Errors.UPGRADE_NEEDED; } if (version > Constants.TSS_KEYGEN_SCHEME_VERSION_MAX) { @@ -447,7 +447,7 @@ class TssSignClass { if (!Number.isFinite(version)) { throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid version provided: ' + params.version); } - if (version < Constants.TSS_SIGGEN_SCHEME_MIN_SERVER_VERSION) { + if (version < Constants.TSS_SIGGEN_SCHEME_VERSION_MIN) { throw Errors.UPGRADE_NEEDED; } if (version > Constants.TSS_SIGGEN_SCHEME_VERSION_MAX) { From 7cc872760a7790f0f0402b2e50081fb4214bc9a2 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Thu, 27 Aug 2026 15:40:25 -0400 Subject: [PATCH 08/13] tss session expiry handling --- .../src/commands/create/createThresholdSig.ts | 20 ++++++++++++------- .../src/commands/join/joinThresholdSig.ts | 20 ++++++++++++------- packages/bitcore-cli/src/tss.ts | 9 ++++++++- .../src/lib/errors/spec.ts | 4 ++++ 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/bitcore-cli/src/commands/create/createThresholdSig.ts b/packages/bitcore-cli/src/commands/create/createThresholdSig.ts index 511ee0ce64c..baac7064c31 100644 --- a/packages/bitcore-cli/src/commands/create/createThresholdSig.ts +++ b/packages/bitcore-cli/src/commands/create/createThresholdSig.ts @@ -1,9 +1,9 @@ import crypto from 'crypto'; import os from 'os'; import url from 'url'; -import { Key, type Network, TssKey } from '@bitpay-labs/bitcore-wallet-client'; +import { Errors, Key, type Network, TssKey } from '@bitpay-labs/bitcore-wallet-client'; import * as prompt from '@clack/prompts'; -import { UserCancelled } from '../../errors'; +import { ProcessCancelled, UserCancelled } from '../../errors'; import { getAddressType, getCopayerName, getPassword, promptKeyshareBackup } from '../../prompts'; import { Utils } from '../../utils'; import { exportWallet } from '../export'; @@ -118,17 +118,23 @@ export async function createThresholdSigWallet( const spinner = prompt.spinner({ indicator: 'timer', onCancel: () => { tss.unsubscribe(); } }); spinner.start('Waiting for all parties to join...'); - await new Promise((resolve, reject) => { + await new Promise((resolve, _reject) => { + let rejected = false; + const reject = (err) => { if (!rejected) { rejected = true; _reject(err); } }; + tss.subscribe({ walletName: wallet.name, copayerName, createWalletOpts: Utils.getSegwitInfo(addressType) }); tss.on('roundsubmitted', (round) => spinner.message(`Round ${round} submitted`)); - tss.on('error', e => prompt.log.error('Unexpected error during TSS wallet creation: ' + (e.stack || e))); - tss.on('wallet', async (_wallet) => { - // TODO: what to do with the wallet? - // console.log('Created wallet at BWS:', wallet); + tss.on('error', e => { + if (e instanceof Errors.TSS_SESSION_EXPIRED) { + tss.unsubscribe({ clearEvents: true }); + spinner.cancel(e.message); + return reject(new ProcessCancelled()); + } + prompt.log.error('Unexpected error during TSS wallet creation: ' + (e.stack || e)); }); tss.on('complete', async () => { try { diff --git a/packages/bitcore-cli/src/commands/join/joinThresholdSig.ts b/packages/bitcore-cli/src/commands/join/joinThresholdSig.ts index 6c95caa4a0a..f6f9e226e91 100644 --- a/packages/bitcore-cli/src/commands/join/joinThresholdSig.ts +++ b/packages/bitcore-cli/src/commands/join/joinThresholdSig.ts @@ -1,8 +1,8 @@ import os from 'os'; import url from 'url'; -import { Key, TssKey } from '@bitpay-labs/bitcore-wallet-client'; +import { Errors, Key, TssKey } from '@bitpay-labs/bitcore-wallet-client'; import * as prompt from '@clack/prompts'; -import { UserCancelled } from '../../errors'; +import { ProcessCancelled, UserCancelled } from '../../errors'; import { getCopayerName, getNetwork, getPassword, promptKeyshareBackup } from '../../prompts'; import { Utils } from '../../utils'; import { exportWallet } from '../export'; @@ -91,7 +91,10 @@ export async function joinThresholdSigWallet( const spinner = prompt.spinner({ indicator: 'timer' }); spinner.start('Waiting for all parties to join...'); - await new Promise((resolve, reject) => { + await new Promise((resolve, _reject) => { + let rejected = false; + const reject = (err) => { if (!rejected) { rejected = true; _reject(err); } }; + process.on('SIGINT', () => { tss.unsubscribe(); spinner.stop('Cancelled by user'); @@ -99,10 +102,13 @@ export async function joinThresholdSigWallet( }); tss.subscribe({ copayerName }); tss.on('roundsubmitted', (round) => spinner.message(`Round ${round} submitted`)); - tss.on('error', e => prompt.log.error('Unexpected error during TSS wallet creation: ' + (e.stack || e))); - tss.on('wallet', async (_wallet) => { - // TOOD: what to do with this? - // console.log('Joined wallet at BWS:', wallet); + tss.on('error', e => { + if (e instanceof Errors.TSS_SESSION_EXPIRED) { + tss.unsubscribe({ clearEvents: true }); + spinner.cancel(e.message); + return reject(new ProcessCancelled()); + } + prompt.log.error('Unexpected error during TSS wallet creation: ' + (e.stack || e)); }); tss.on('complete', async () => { try { diff --git a/packages/bitcore-cli/src/tss.ts b/packages/bitcore-cli/src/tss.ts index e4a3b50cb0b..41c29ec9c43 100644 --- a/packages/bitcore-cli/src/tss.ts +++ b/packages/bitcore-cli/src/tss.ts @@ -78,7 +78,10 @@ export async function sign(args: { 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) => { + const sig = await new Promise>((resolve, _reject) => { + let rejected = false; + const reject = (err) => { if (!rejected) { rejected = true; _reject(err); } }; + tssSign.subscribe(); tssSign.on('roundsubmitted', (round) => { storeSession(tssSign.exportSession()); @@ -89,6 +92,10 @@ export async function sign(args: { tssSign.unsubscribe({ clearEvents: true }); spinner.cancel('TSS session not found. It may have been deleted by another party.'); return reject(new ProcessCancelled()); + } else if (e instanceof Errors.TSS_SESSION_EXPIRED) { + tssSign.unsubscribe({ clearEvents: true }); + spinner.cancel(e.message); + return reject(new ProcessCancelled()); } prompt.log.error('Unexpected error during TSS signing: ' + (e.stack || e)); }); diff --git a/packages/bitcore-wallet-client/src/lib/errors/spec.ts b/packages/bitcore-wallet-client/src/lib/errors/spec.ts index f0f802aed22..59022dc90ec 100644 --- a/packages/bitcore-wallet-client/src/lib/errors/spec.ts +++ b/packages/bitcore-wallet-client/src/lib/errors/spec.ts @@ -212,5 +212,9 @@ export const errorSpec: IErrorSpec[] = [ { name: 'UPGRADE_NEEDED', message: 'Please update your client.' + }, + { + name: 'TSS_SESSION_EXPIRED', + message: 'TSS session expired.' } ]; \ No newline at end of file From f6219e0c0c51988872137b034b422ad5bebd1b89 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Thu, 27 Aug 2026 15:40:42 -0400 Subject: [PATCH 09/13] better tssVersion assignment --- packages/bitcore-wallet-service/src/lib/server.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index 264fbd71691..a9a1380df02 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -668,15 +668,17 @@ export class WalletService implements IWalletService { } if (opts.tssKeyId) { - opts.tssVersion = opts.tssVersion || Defaults.TSS_KEYGEN_SCHEME_VERSION; - if (!(opts.tssVersion > 0 && opts.tssVersion <= Constants.TSS_KEYGEN_SCHEME_VERSION_MAX)) { - return cb(new ClientError('Invalid TSS version')); - } - const keySession = await storage.fetchTssKeyGenSession({ id: opts.tssKeyId }); if (!keySession || !keySession.sharedPublicKey) { + // TODO: support importing a TSS key that was generated outside of this BWS instance. return cb(new ClientError('Invalid TSS key session id')); } + + opts.tssVersion = opts.tssVersion || keySession.schemeVersion || Defaults.TSS_KEYGEN_SCHEME_VERSION; + if (!(opts.tssVersion > 0 && opts.tssVersion <= Constants.TSS_KEYGEN_SCHEME_VERSION_MAX)) { + return cb(new ClientError('Invalid TSS version')); + } + // TSS wallets behave like a single-sig opts.m = 1; opts.n = 1; From 9c4a8426c189211ae0eb0ca2234ba5ca63d07606 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Thu, 27 Aug 2026 15:45:33 -0400 Subject: [PATCH 10/13] consider minimum supported tss key scheme version --- packages/bitcore-wallet-service/src/lib/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index a9a1380df02..f456cf34a1d 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -675,7 +675,7 @@ export class WalletService implements IWalletService { } opts.tssVersion = opts.tssVersion || keySession.schemeVersion || Defaults.TSS_KEYGEN_SCHEME_VERSION; - if (!(opts.tssVersion > 0 && opts.tssVersion <= Constants.TSS_KEYGEN_SCHEME_VERSION_MAX)) { + if (!(opts.tssVersion >= Constants.TSS_KEYGEN_SCHEME_VERSION_MIN && opts.tssVersion <= Constants.TSS_KEYGEN_SCHEME_VERSION_MAX)) { return cb(new ClientError('Invalid TSS version')); } From 7c04a80ae26331b3bc216b9db27f5a70f4a253e5 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Thu, 27 Aug 2026 16:02:06 -0400 Subject: [PATCH 11/13] fix expiry tests --- packages/bitcore-wallet-client/test/tss.test.ts | 13 +++++++------ .../src/lib/errors/errordefinitions.ts | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/bitcore-wallet-client/test/tss.test.ts b/packages/bitcore-wallet-client/test/tss.test.ts index c4c666df941..e97a8fb91c5 100644 --- a/packages/bitcore-wallet-client/test/tss.test.ts +++ b/packages/bitcore-wallet-client/test/tss.test.ts @@ -19,6 +19,7 @@ import { helpers, blockchainExplorerMock } from './helpers'; +import type { IErrorSpec } from '../src/lib/errors/spec'; const should = chai.should(); const datadir = path.join(__dirname, 'data'); @@ -1211,7 +1212,7 @@ describe('TSS', function() { simulateExpiry(BwsDefaults.TSS_KEYGEN_TIME_LIMIT + 1); // 1 minute past the default limit - const error0 = new Promise(r => tss0.once('error', (e) => { tss0.unsubscribe(); r(e); })); + 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() }); @@ -1219,11 +1220,11 @@ describe('TSS', function() { tss2.subscribe({ timeout: 10, iterHandler: () => tss2.unsubscribe() }); const err0 = await error0; - err0.message.should.include('TSS_SESSION_EXPIRED'); + err0.name.should.include('TSS_SESSION_EXPIRED'); const err1 = await error1; - err1.message.should.include('TSS_SESSION_EXPIRED'); + err1.name.should.include('TSS_SESSION_EXPIRED'); const err2 = await error2; - err2.message.should.include('TSS_SESSION_EXPIRED'); + err2.name.should.include('TSS_SESSION_EXPIRED'); }); it('should not expire a session before the time limit is reached', async function() { @@ -1294,10 +1295,10 @@ describe('TSS', function() { simulateExpiry(BwsDefaults.TSS_SIGGEN_TIME_LIMIT + 1); // 1 minute past the default limit - const error = new Promise(r => sig0.once('error', (e) => { sig0.unsubscribe(); r(e); })); + 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'); + err.name.should.include('TSS_SESSION_EXPIRED'); }); it('should not expire a signing session before the time limit is reached', async function() { diff --git a/packages/bitcore-wallet-service/src/lib/errors/errordefinitions.ts b/packages/bitcore-wallet-service/src/lib/errors/errordefinitions.ts index a458a8077f2..bf9118cd140 100644 --- a/packages/bitcore-wallet-service/src/lib/errors/errordefinitions.ts +++ b/packages/bitcore-wallet-service/src/lib/errors/errordefinitions.ts @@ -151,7 +151,7 @@ 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_SESSION_EXPIRED: 'TSS 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', }; From a398185de695cbc2e0397fb0509f20544372f270 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Fri, 28 Aug 2026 12:31:09 -0400 Subject: [PATCH 12/13] throw a 500 error for authTssRequest bad path --- .../src/lib/routes/middleware/authTssRequest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9f791a63086..cd21eba0056 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/middleware/authTssRequest.ts @@ -41,7 +41,7 @@ export function authTssRequest(): express.RequestHandler { partyId = session?.participants.find(p => p.copayerId === copayerId)?.partyId; pubKey = partyId == null ? null : session?.rounds[0]?.find(r => r.fromPartyId === partyId)?.messages.publicKey; } else { - throw Errors.NOT_AUTHORIZED.withMessage('Invalid TSS request path'); + throw new Error('Unknown TSS request path: ' + req.path); } if (!session) { From 5bac8d901ed5a53fc38507fef709dbf6fe9fc069 Mon Sep 17 00:00:00 2001 From: Kenny Joseph Date: Fri, 28 Aug 2026 12:44:18 -0400 Subject: [PATCH 13/13] cleanup session state on terminal exit --- packages/bitcore-cli/src/tss.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/bitcore-cli/src/tss.ts b/packages/bitcore-cli/src/tss.ts index 41c29ec9c43..a69be034cd1 100644 --- a/packages/bitcore-cli/src/tss.ts +++ b/packages/bitcore-cli/src/tss.ts @@ -38,6 +38,11 @@ export async function sign(args: { fs.writeFileSync(storedSessionFile, encrypted, 'utf8'); }; + const rmSessionState = () => { + // Clean up the stored session file after successful signing/terminal exit + fs.rmSync(storedSessionFile, { force: true }); + }; + const tssSign = new TssSign.TssSign({ baseUrl: url.resolve(host, '/bws/api'), credentials: walletData.credentials, @@ -91,10 +96,12 @@ export async function sign(args: { 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.'); + rmSessionState(); return reject(new ProcessCancelled()); } else if (e instanceof Errors.TSS_SESSION_EXPIRED) { tssSign.unsubscribe({ clearEvents: true }); spinner.cancel(e.message); + rmSessionState(); return reject(new ProcessCancelled()); } prompt.log.error('Unexpected error during TSS signing: ' + (e.stack || e)); @@ -102,8 +109,7 @@ export async function sign(args: { tssSign.on('complete', async () => { try { spinner.stop(logMessageCompleted || 'TSS signature generated'); - // Clean up the stored session file after successful signing - fs.rmSync(storedSessionFile, { force: true }); + rmSessionState(); const signature: TssSign.ISignature = tssSign.getSignature(); const sigString = transformISignature(signature); resolve({