@bitpay-labs/bitcore-wallet-service 11.10.7 → 11.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +3 -3
  2. package/src/externalservices/coingecko.ts +24 -10
  3. package/src/externalservices/moonpay.ts +23 -1
  4. package/src/lib/chain/eth/abi-invoice.ts +139 -90
  5. package/src/lib/common/constants.ts +5 -2
  6. package/src/lib/common/defaults.ts +11 -2
  7. package/src/lib/errors/errordefinitions.ts +6 -0
  8. package/src/lib/model/copayer.ts +1 -0
  9. package/src/lib/model/tsskeygen.ts +33 -4
  10. package/src/lib/model/tsssign.ts +34 -3
  11. package/src/lib/pushnotificationsservice.ts +3 -1
  12. package/src/lib/routes/middleware/authTssRequest.ts +10 -4
  13. package/src/lib/routes/middleware/verifyTssMessage.ts +13 -8
  14. package/src/lib/routes/tss.ts +11 -4
  15. package/src/lib/server.ts +53 -25
  16. package/src/lib/storage.ts +24 -1
  17. package/src/lib/tss.ts +232 -30
  18. package/src/types/externalservices.d.ts +29 -1
  19. package/test/integration/externalservices/coinGecko.test.ts +71 -7
  20. package/test/integration/server.test.ts +731 -1
  21. package/test/integration/tss.test.ts +275 -0
  22. package/test/model/copayer.test.ts +128 -0
  23. package/test/storage.test.ts +91 -0
  24. package/ts_build/package.json +1 -1
  25. package/ts_build/src/externalservices/coingecko.d.ts.map +1 -1
  26. package/ts_build/src/externalservices/coingecko.js +14 -8
  27. package/ts_build/src/externalservices/coingecko.js.map +1 -1
  28. package/ts_build/src/externalservices/moonpay.d.ts.map +1 -1
  29. package/ts_build/src/externalservices/moonpay.js +38 -2
  30. package/ts_build/src/externalservices/moonpay.js.map +1 -1
  31. package/ts_build/src/lib/chain/eth/abi-invoice.d.ts +254 -41
  32. package/ts_build/src/lib/chain/eth/abi-invoice.d.ts.map +1 -1
  33. package/ts_build/src/lib/chain/eth/abi-invoice.js +138 -89
  34. package/ts_build/src/lib/chain/eth/abi-invoice.js.map +1 -1
  35. package/ts_build/src/lib/common/constants.d.ts +4 -2
  36. package/ts_build/src/lib/common/constants.d.ts.map +1 -1
  37. package/ts_build/src/lib/common/constants.js +4 -2
  38. package/ts_build/src/lib/common/constants.js.map +1 -1
  39. package/ts_build/src/lib/common/defaults.d.ts +4 -2
  40. package/ts_build/src/lib/common/defaults.d.ts.map +1 -1
  41. package/ts_build/src/lib/common/defaults.js +4 -2
  42. package/ts_build/src/lib/common/defaults.js.map +1 -1
  43. package/ts_build/src/lib/common/index.d.ts +8 -4
  44. package/ts_build/src/lib/common/index.d.ts.map +1 -1
  45. package/ts_build/src/lib/errors/errordefinitions.d.ts +3 -0
  46. package/ts_build/src/lib/errors/errordefinitions.d.ts.map +1 -1
  47. package/ts_build/src/lib/errors/errordefinitions.js +3 -0
  48. package/ts_build/src/lib/errors/errordefinitions.js.map +1 -1
  49. package/ts_build/src/lib/model/copayer.d.ts.map +1 -1
  50. package/ts_build/src/lib/model/copayer.js +1 -0
  51. package/ts_build/src/lib/model/copayer.js.map +1 -1
  52. package/ts_build/src/lib/model/tsskeygen.d.ts +3 -0
  53. package/ts_build/src/lib/model/tsskeygen.d.ts.map +1 -1
  54. package/ts_build/src/lib/model/tsskeygen.js +12 -3
  55. package/ts_build/src/lib/model/tsskeygen.js.map +1 -1
  56. package/ts_build/src/lib/model/tsssign.d.ts +7 -0
  57. package/ts_build/src/lib/model/tsssign.d.ts.map +1 -1
  58. package/ts_build/src/lib/model/tsssign.js +22 -2
  59. package/ts_build/src/lib/model/tsssign.js.map +1 -1
  60. package/ts_build/src/lib/pushnotificationsservice.d.ts.map +1 -1
  61. package/ts_build/src/lib/pushnotificationsservice.js +3 -1
  62. package/ts_build/src/lib/pushnotificationsservice.js.map +1 -1
  63. package/ts_build/src/lib/routes/middleware/authTssRequest.d.ts.map +1 -1
  64. package/ts_build/src/lib/routes/middleware/authTssRequest.js +8 -2
  65. package/ts_build/src/lib/routes/middleware/authTssRequest.js.map +1 -1
  66. package/ts_build/src/lib/routes/middleware/verifyTssMessage.d.ts.map +1 -1
  67. package/ts_build/src/lib/routes/middleware/verifyTssMessage.js +11 -6
  68. package/ts_build/src/lib/routes/middleware/verifyTssMessage.js.map +1 -1
  69. package/ts_build/src/lib/routes/tss.d.ts.map +1 -1
  70. package/ts_build/src/lib/routes/tss.js +4 -4
  71. package/ts_build/src/lib/routes/tss.js.map +1 -1
  72. package/ts_build/src/lib/server.d.ts.map +1 -1
  73. package/ts_build/src/lib/server.js +33 -25
  74. package/ts_build/src/lib/server.js.map +1 -1
  75. package/ts_build/src/lib/storage.d.ts +4 -0
  76. package/ts_build/src/lib/storage.d.ts.map +1 -1
  77. package/ts_build/src/lib/storage.js +18 -1
  78. package/ts_build/src/lib/storage.js.map +1 -1
  79. package/ts_build/src/lib/tss.d.ts +4 -0
  80. package/ts_build/src/lib/tss.d.ts.map +1 -1
  81. package/ts_build/src/lib/tss.js +74 -17
  82. package/ts_build/src/lib/tss.js.map +1 -1
  83. package/ts_build/test/integration/externalservices/coinGecko.test.js +62 -8
  84. package/ts_build/test/integration/externalservices/coinGecko.test.js.map +1 -1
  85. package/ts_build/test/integration/server.test.js +597 -0
  86. package/ts_build/test/integration/server.test.js.map +1 -1
  87. package/ts_build/test/integration/tss.test.js +245 -0
  88. package/ts_build/test/integration/tss.test.js.map +1 -1
  89. package/ts_build/test/model/copayer.test.js +91 -0
  90. package/ts_build/test/model/copayer.test.js.map +1 -1
  91. package/ts_build/test/storage.test.js +73 -0
  92. package/ts_build/test/storage.test.js.map +1 -1
@@ -4,6 +4,8 @@ import * as chai from 'chai';
4
4
  import 'chai/register-should';
5
5
  import util from 'util';
6
6
  import sinon from 'sinon';
7
+ import http from 'http';
8
+ import request from 'request';
7
9
  import * as CWC from '@bitpay-labs/crypto-wallet-core';
8
10
  import { ChainService } from '../../src/lib/chain/index';
9
11
  import config from '../../src/config';
@@ -11,10 +13,12 @@ import { WalletService, UPGRADES } from '../../src/lib/server';
11
13
  import { Storage } from '../../src/lib/storage';
12
14
  import { Common } from '../../src/lib/common';
13
15
  import * as Model from '../../src/lib/model';
16
+ import { TssKeyGenModel } from '../../src/lib/model/tsskeygen';
14
17
  import { BCHAddressTranslator } from '../../src/lib/bchaddresstranslator';
15
18
  import * as TestData from '../testdata';
16
19
  import helpers from './helpers';
17
20
  import { ClientError } from '../../src/lib/errors/clienterror';
21
+ import { ExpressApp } from '../../src/lib/expressapp';
18
22
 
19
23
  const should = chai.should();
20
24
  config.moralis = config.moralis ?? {
@@ -1135,6 +1139,271 @@ describe('Wallet service', function() {
1135
1139
  });
1136
1140
  });
1137
1141
 
1142
+ it('should fail to join with wrong signature even when hardwareSourcePublicKey is present', function(done) {
1143
+ const copayerOpts = helpers.getSignedCopayerOpts({
1144
+ walletId: walletId,
1145
+ name: 'me',
1146
+ xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H,
1147
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1148
+ });
1149
+ copayerOpts.name = 'me2'; // invalidates the signature, same as the plain wrong-signature case above
1150
+ copayerOpts.hardwareSourcePublicKey = 'attacker-controlled-hw-pubkey';
1151
+ server.joinWallet(copayerOpts, function(err, result) {
1152
+ should.not.exist(result);
1153
+ should.exist(err);
1154
+ err.message.should.equal('Bad request');
1155
+ // The rejected join must not have persisted a copayer despite carrying
1156
+ // hardwareSourcePublicKey - a regression that returns an error but still adds the
1157
+ // copayer would pass the assertions above alone.
1158
+ server.getWallet({}, function(err, wallet) {
1159
+ should.not.exist(err);
1160
+ wallet.copayers.length.should.equal(0);
1161
+ done();
1162
+ });
1163
+ });
1164
+ });
1165
+
1166
+ it('should fail to join with wrong signature even when clientDerivedPublicKey is present', function(done) {
1167
+ const copayerOpts = helpers.getSignedCopayerOpts({
1168
+ walletId: walletId,
1169
+ name: 'me',
1170
+ xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H,
1171
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1172
+ });
1173
+ copayerOpts.name = 'me2'; // invalidates the signature, same as the plain wrong-signature case above
1174
+ copayerOpts.clientDerivedPublicKey = 'attacker-controlled-client-derived-pubkey';
1175
+ server.joinWallet(copayerOpts, function(err, result) {
1176
+ should.not.exist(result);
1177
+ should.exist(err);
1178
+ err.message.should.equal('Bad request');
1179
+ // Same persisted-state guard as the hardwareSourcePublicKey case above.
1180
+ server.getWallet({}, function(err, wallet) {
1181
+ should.not.exist(err);
1182
+ wallet.copayers.length.should.equal(0);
1183
+ done();
1184
+ });
1185
+ });
1186
+ });
1187
+
1188
+ it('should join existing wallet with a valid signature and hardwareSourcePublicKey present', function(done) {
1189
+ const copayerOpts = helpers.getSignedCopayerOpts({
1190
+ walletId: walletId,
1191
+ name: 'me',
1192
+ xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H,
1193
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1194
+ });
1195
+ copayerOpts.hardwareSourcePublicKey = 'legit-hw-pubkey';
1196
+ server.joinWallet(copayerOpts, function(err, result) {
1197
+ should.not.exist(err);
1198
+ should.exist(result);
1199
+ should.exist(result.copayerId);
1200
+ server.getWallet({}, function(err, wallet) {
1201
+ should.not.exist(err);
1202
+ wallet.copayers.length.should.equal(1);
1203
+ const copayer = wallet.copayers[0];
1204
+ copayer.id.should.equal(result.copayerId);
1205
+ copayer.hardwareSourcePublicKey.should.equal('legit-hw-pubkey');
1206
+ done();
1207
+ });
1208
+ });
1209
+ });
1210
+
1211
+ it('should join existing wallet with a valid signature and clientDerivedPublicKey present', function(done) {
1212
+ const copayerOpts = helpers.getSignedCopayerOpts({
1213
+ walletId: walletId,
1214
+ name: 'me',
1215
+ xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H,
1216
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1217
+ });
1218
+ copayerOpts.clientDerivedPublicKey = 'legit-client-derived-pubkey';
1219
+ server.joinWallet(copayerOpts, function(err, result) {
1220
+ should.not.exist(err);
1221
+ should.exist(result);
1222
+ should.exist(result.copayerId);
1223
+ server.getWallet({}, function(err, wallet) {
1224
+ should.not.exist(err);
1225
+ wallet.copayers.length.should.equal(1);
1226
+ const copayer = wallet.copayers[0];
1227
+ copayer.id.should.equal(result.copayerId);
1228
+ copayer.clientDerivedPublicKey.should.equal('legit-client-derived-pubkey');
1229
+ done();
1230
+ });
1231
+ });
1232
+ });
1233
+
1234
+ it('should reject a join with hardwareSourcePublicKey present when xPubKey is absent', function(done) {
1235
+ // xPubKey is the canonical copayer identity for every join; the ancillary
1236
+ // hardwareSourcePublicKey field is metadata and must not stand in for it. The
1237
+ // signature is created while xPubKey is genuinely absent - it covers
1238
+ // [name, undefined, requestPubKey].join('|') === 'me||<requestPubKey>' - so the
1239
+ // rejection below is attributable to the missing identity field, not to a
1240
+ // signature invalidated by deleting an xpub after signing.
1241
+ const copayerOpts: any = helpers.getSignedCopayerOpts({
1242
+ walletId: walletId,
1243
+ name: 'me',
1244
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1245
+ });
1246
+ should.not.exist(copayerOpts.xPubKey);
1247
+ copayerOpts.hardwareSourcePublicKey = 'legit-hw-pubkey-no-xpub';
1248
+ server.joinWallet(copayerOpts, function(err, result) {
1249
+ should.not.exist(result);
1250
+ should.exist(err);
1251
+ err.should.be.instanceof(ClientError);
1252
+ // The rejected join must not have persisted a copayer. The join is rejected
1253
+ // before WalletService.walletId is set, so an unauthenticated getWallet({})
1254
+ // cannot address the wallet, and there is no copayerId to authenticate with;
1255
+ // read persisted state at the storage level instead.
1256
+ server.storage.fetchWallet(walletId, function(err, wallet) {
1257
+ should.not.exist(err);
1258
+ should.exist(wallet);
1259
+ wallet.copayers.length.should.equal(0);
1260
+ done();
1261
+ });
1262
+ });
1263
+ });
1264
+
1265
+ // Table-driven rather than one near-identical 'it' per field: the point of each case is
1266
+ // that this *particular* ancillary field must not stand in for xPubKey, so looping over
1267
+ // the field name keeps that assertion identical across cases and makes it obvious if a
1268
+ // future ancillary field (a third credential type) is added without a matching case here.
1269
+ for (const ancillaryField of ['hardwareSourcePublicKey', 'clientDerivedPublicKey']) {
1270
+ it(`should reject an xPubKey-less ${ancillaryField} join without persisting a copayer`, function(done) {
1271
+ const copayerOpts: any = helpers.getSignedCopayerOpts({
1272
+ walletId: walletId,
1273
+ name: 'me',
1274
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1275
+ });
1276
+ should.not.exist(copayerOpts.xPubKey);
1277
+ copayerOpts[ancillaryField] = 'legit-value-no-xpub';
1278
+ server.joinWallet(copayerOpts, function(err, result) {
1279
+ should.not.exist(result);
1280
+ should.exist(err);
1281
+ err.should.be.instanceof(ClientError);
1282
+ // The join is rejected before WalletService.walletId is set, so an unauthenticated
1283
+ // getWallet({}) cannot address the wallet; read persisted state at the storage level.
1284
+ server.storage.fetchWallet(walletId, function(err, wallet) {
1285
+ should.not.exist(err);
1286
+ should.exist(wallet);
1287
+ wallet.copayers.length.should.equal(0);
1288
+ done();
1289
+ });
1290
+ });
1291
+ });
1292
+ }
1293
+
1294
+ it('should fail to join with a malformed xPubKey even when hardwareSourcePublicKey is present', function(done) {
1295
+ const copayerOpts = helpers.getSignedCopayerOpts({
1296
+ walletId: walletId,
1297
+ name: 'me',
1298
+ xPubKey: 'invalid',
1299
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1300
+ });
1301
+ copayerOpts.hardwareSourcePublicKey = 'legit-hw-pubkey';
1302
+ server.joinWallet(copayerOpts, function(err, result) {
1303
+ should.not.exist(result);
1304
+ should.exist(err);
1305
+ err.should.be.instanceof(ClientError);
1306
+ // The rejected join must not have persisted a copayer; read persisted state
1307
+ // at the storage level (the join may be rejected before WalletService.walletId
1308
+ // is set, in which case getWallet({}) cannot address the wallet).
1309
+ server.storage.fetchWallet(walletId, function(err, wallet) {
1310
+ should.not.exist(err);
1311
+ should.exist(wallet);
1312
+ wallet.copayers.length.should.equal(0);
1313
+ done();
1314
+ });
1315
+ });
1316
+ });
1317
+
1318
+ it('should fail to join with a malformed xPubKey even when clientDerivedPublicKey is present', function(done) {
1319
+ const copayerOpts = helpers.getSignedCopayerOpts({
1320
+ walletId: walletId,
1321
+ name: 'me',
1322
+ xPubKey: 'invalid',
1323
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1324
+ });
1325
+ copayerOpts.clientDerivedPublicKey = 'legit-client-derived-pubkey';
1326
+ server.joinWallet(copayerOpts, function(err, result) {
1327
+ should.not.exist(result);
1328
+ should.exist(err);
1329
+ err.should.be.instanceof(ClientError);
1330
+ // Same persisted-state guard as the hardwareSourcePublicKey case above.
1331
+ server.storage.fetchWallet(walletId, function(err, wallet) {
1332
+ should.not.exist(err);
1333
+ should.exist(wallet);
1334
+ wallet.copayers.length.should.equal(0);
1335
+ done();
1336
+ });
1337
+ });
1338
+ });
1339
+
1340
+ it('should reject a join when xPubKey, hardwareSourcePublicKey and clientDerivedPublicKey are all missing or empty', function(done) {
1341
+ const copayerOpts: any = helpers.getSignedCopayerOpts({
1342
+ walletId: walletId,
1343
+ name: 'me',
1344
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1345
+ });
1346
+ should.not.exist(copayerOpts.xPubKey);
1347
+ copayerOpts.hardwareSourcePublicKey = '';
1348
+ copayerOpts.clientDerivedPublicKey = '';
1349
+ server.joinWallet(copayerOpts, function(err, result) {
1350
+ should.not.exist(result);
1351
+ should.exist(err);
1352
+ err.should.be.instanceof(ClientError);
1353
+ // The rejected join must not have persisted a copayer. The join is rejected
1354
+ // before WalletService.walletId is set, so an unauthenticated getWallet({})
1355
+ // cannot address the wallet, and there is no copayerId to authenticate with;
1356
+ // read persisted state at the storage level instead.
1357
+ server.storage.fetchWallet(walletId, function(err, wallet) {
1358
+ should.not.exist(err);
1359
+ should.exist(wallet);
1360
+ wallet.copayers.length.should.equal(0);
1361
+ done();
1362
+ });
1363
+ });
1364
+ });
1365
+
1366
+ it('should fail to join a full wallet with a valid signature and hardwareSourcePublicKey present', function(done) {
1367
+ // walletId is m=1/n=2 (see beforeEach above); fill both slots with normal joins first
1368
+ // so the third, hardware-flagged join hits WALLET_FULL rather than succeeding.
1369
+ const firstCopayerOpts = helpers.getSignedCopayerOpts({
1370
+ walletId: walletId,
1371
+ name: 'copayer 1',
1372
+ xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H,
1373
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1374
+ });
1375
+ server.joinWallet(firstCopayerOpts, function(err) {
1376
+ should.not.exist(err);
1377
+ const secondCopayerOpts = helpers.getSignedCopayerOpts({
1378
+ walletId: walletId,
1379
+ name: 'copayer 2',
1380
+ xPubKey: TestData.copayers[1].xPubKey_44H_0H_0H,
1381
+ requestPubKey: TestData.copayers[1].pubKey_1H_0,
1382
+ });
1383
+ server.joinWallet(secondCopayerOpts, function(err) {
1384
+ should.not.exist(err);
1385
+ const thirdCopayerOpts = helpers.getSignedCopayerOpts({
1386
+ walletId: walletId,
1387
+ name: 'copayer 3',
1388
+ xPubKey: TestData.copayers[2].xPubKey_44H_0H_0H,
1389
+ requestPubKey: TestData.copayers[2].pubKey_1H_0,
1390
+ });
1391
+ thirdCopayerOpts.hardwareSourcePublicKey = 'attacker-or-legit-hw-pubkey';
1392
+ server.joinWallet(thirdCopayerOpts, function(err, result) {
1393
+ should.not.exist(result);
1394
+ should.exist(err);
1395
+ err.should.be.instanceof(ClientError);
1396
+ err.code.should.equal('WALLET_FULL');
1397
+ server.getWallet({}, function(err, wallet) {
1398
+ should.not.exist(err);
1399
+ wallet.copayers.length.should.equal(2);
1400
+ done();
1401
+ });
1402
+ });
1403
+ });
1404
+ });
1405
+ });
1406
+
1138
1407
  it('should set pkr and status = complete on last copayer joining (2-3)', function(done) {
1139
1408
  helpers.createAndJoinWallet(2, 3).then(function({ server }) {
1140
1409
  server.getWallet({}, function(err, wallet) {
@@ -1376,6 +1645,133 @@ describe('Wallet service', function() {
1376
1645
  });
1377
1646
  });
1378
1647
  });
1648
+
1649
+ // SECURITY: this guards against a copayer joining a TSS wallet without ever being checked
1650
+ // against the key generation session's participant list.
1651
+ describe('TSS wallets (non-participant bypass)', function() {
1652
+ it('should reject a join from a copayer who is not a TSS keygen session participant, even with clientDerivedPublicKey supplied', async function() {
1653
+ const server = new WalletService();
1654
+
1655
+ const legitXPubKey = TestData.copayers[0].xPubKey_44H_0H_0H;
1656
+ const legitCopayerId = Model.Copayer.xPubToCopayerId('btc', legitXPubKey);
1657
+
1658
+ // A completed TSS key-gen session whose sole participant is the legit copayer above.
1659
+ // NOTE: helpers.beforeEach()'s collection wipe-list doesn't include tss_keygen (it's
1660
+ // marked // TODO in helpers.ts), so this collection isn't reset between test runs the
1661
+ // way the rest of the suite's state is. Clear our fixture id explicitly so this test
1662
+ // stays repeatable regardless of that pre-existing gap.
1663
+ const session = TssKeyGenModel.create({
1664
+ id: 'tss-non-participant-bypass-test-session',
1665
+ message: { partyId: 0, broadcastMessages: [], p2pMessages: [], publicKey: 'dummy', round: 0 },
1666
+ n: 1,
1667
+ copayerId: legitCopayerId,
1668
+ version: 1.1,
1669
+ });
1670
+ session.sharedPublicKey = 'dummy-shared-public-key';
1671
+ await server.storage.db.collection('tss_keygen').deleteMany({ id: session.id });
1672
+ await server.storage.storeTssKeyGenSession({ doc: session });
1673
+
1674
+ // createWallet forces m=n=1 for any wallet created against a tssKeyId.
1675
+ const walletId = await util.promisify(server.createWallet).call(server, {
1676
+ name: 'tss wallet',
1677
+ m: 1,
1678
+ n: 1,
1679
+ pubKey: TestData.keyPair.pub,
1680
+ coin: 'btc',
1681
+ tssKeyId: session.id,
1682
+ });
1683
+ should.exist(walletId);
1684
+
1685
+ // Attacker knows the wallet's join secret (so they CAN produce a valid
1686
+ // copayerSignature - e.g. they were handed the invite link/secret for this wallet)
1687
+ // but their copayerId is NOT in session.participants: they never actually took part
1688
+ // in the TSS key generation. A valid copayerSignature alone must not be enough to
1689
+ // join a TSS wallet; only listed DKG participants may.
1690
+ const copayerOpts: any = helpers.getSignedCopayerOpts({
1691
+ walletId,
1692
+ name: 'attacker',
1693
+ xPubKey: TestData.copayers[1].xPubKey_44H_0H_0H,
1694
+ clientDerivedPublicKey: 'attacker-controlled-client-derived-pubkey',
1695
+ requestPubKey: TestData.copayers[1].pubKey_1H_0,
1696
+ });
1697
+
1698
+ const { err, result } = await new Promise<{ err: any; result: any }>(resolve => {
1699
+ server.joinWallet(copayerOpts, (err, result) => resolve({ err, result }));
1700
+ });
1701
+
1702
+ should.not.exist(result);
1703
+ should.exist(err);
1704
+ err.code.should.equal('TSS_NON_PARTICIPANT');
1705
+
1706
+ // The rejected join must not have persisted the attacker as a copayer despite
1707
+ // supplying a valid copayerSignature - a regression that returns TSS_NON_PARTICIPANT
1708
+ // but still adds the copayer would pass the assertions above alone.
1709
+ const wallet = await util.promisify(server.getWallet).call(server, {});
1710
+ wallet.copayers.length.should.equal(0);
1711
+ });
1712
+
1713
+ it('should reject a TSS join with clientDerivedPublicKey present when xPubKey is absent, even if the ancillary-derived ID is a keygen session participant', async function() {
1714
+ // xPubKey is the canonical copayer identity for every join, including TSS ones;
1715
+ // the ancillary clientDerivedPublicKey field must not stand in for it. The
1716
+ // session participant list below carries the ID derived from
1717
+ // clientDerivedPublicKey, so this proves the join is rejected for the missing
1718
+ // identity field itself rather than by the TSS participant check. The signature
1719
+ // is created while xPubKey is genuinely absent - it covers
1720
+ // [name, undefined, requestPubKey].join('|') === 'me||<requestPubKey>' - so it is
1721
+ // valid for the request as sent and the rejection is attributable to the missing
1722
+ // xPubKey, not to an invalidated signature.
1723
+
1724
+ // 20 Aug '26: Support for joining a wallet without xPubKey will fast-follow
1725
+ const server = new WalletService();
1726
+
1727
+ const clientDerivedPublicKey = 'legit-client-derived-pubkey-no-xpub';
1728
+ const ancillaryDerivedCopayerId = Model.Copayer.xPubToCopayerId('btc', clientDerivedPublicKey);
1729
+
1730
+ const session = TssKeyGenModel.create({
1731
+ id: 'tss-no-xpubkey-rejected-test-session',
1732
+ message: { partyId: 0, broadcastMessages: [], p2pMessages: [], publicKey: 'dummy', round: 0 },
1733
+ n: 1,
1734
+ copayerId: ancillaryDerivedCopayerId,
1735
+ version: 1.1,
1736
+ });
1737
+ session.sharedPublicKey = 'dummy-shared-public-key';
1738
+ await server.storage.db.collection('tss_keygen').deleteMany({ id: session.id });
1739
+ await server.storage.storeTssKeyGenSession({ doc: session });
1740
+
1741
+ const walletId = await util.promisify(server.createWallet).call(server, {
1742
+ name: 'tss wallet',
1743
+ m: 1,
1744
+ n: 1,
1745
+ pubKey: TestData.keyPair.pub,
1746
+ coin: 'btc',
1747
+ tssKeyId: session.id,
1748
+ });
1749
+ should.exist(walletId);
1750
+
1751
+ const copayerOpts: any = helpers.getSignedCopayerOpts({
1752
+ walletId,
1753
+ name: 'me',
1754
+ clientDerivedPublicKey,
1755
+ requestPubKey: TestData.copayers[0].pubKey_1H_0,
1756
+ });
1757
+ should.not.exist(copayerOpts.xPubKey);
1758
+
1759
+ const { err, result } = await new Promise<{ err: any; result: any }>(resolve => {
1760
+ server.joinWallet(copayerOpts, (err, result) => resolve({ err, result }));
1761
+ });
1762
+
1763
+ should.not.exist(result);
1764
+ should.exist(err);
1765
+ err.should.be.instanceof(ClientError);
1766
+
1767
+ // The rejected join must not have persisted a copayer. The join is rejected
1768
+ // before WalletService.walletId is set, so an unauthenticated getWallet({})
1769
+ // cannot address the wallet; read persisted state at the storage level instead.
1770
+ const wallet = await util.promisify(server.storage.fetchWallet).call(server.storage, walletId);
1771
+ should.exist(wallet);
1772
+ wallet.copayers.length.should.equal(0);
1773
+ });
1774
+ });
1379
1775
  });
1380
1776
 
1381
1777
  describe('#removeWallet', function() {
@@ -2805,6 +3201,112 @@ describe('Wallet service', function() {
2805
3201
  });
2806
3202
  });
2807
3203
  });
3204
+
3205
+ describe('#add access with historical copayer state', function() {
3206
+ beforeEach(async function() {
3207
+ ({ server, wallet } = await helpers.createAndJoinWallet(1, 1));
3208
+ });
3209
+
3210
+ function snapshotState(cb) {
3211
+ server.storage.db.collection(Storage.collections.WALLETS).findOne(
3212
+ { id: wallet.id },
3213
+ (err, walletDoc) => {
3214
+ if (err) return cb(err);
3215
+ server.storage.db.collection(Storage.collections.COPAYERS_LOOKUP).findOne(
3216
+ { copayerId: opts.copayerId },
3217
+ (err2, lookupDoc) => {
3218
+ if (err2) return cb(err2);
3219
+ return cb(null, {
3220
+ wallet: JSON.parse(JSON.stringify(walletDoc)),
3221
+ lookup: JSON.parse(JSON.stringify(lookupDoc))
3222
+ });
3223
+ }
3224
+ );
3225
+ }
3226
+ );
3227
+ }
3228
+
3229
+ function mutateWalletDoc(mutation, cb) {
3230
+ server.storage.db.collection(Storage.collections.WALLETS).findOne(
3231
+ { id: wallet.id },
3232
+ (err, walletDoc) => {
3233
+ if (err) return cb(err);
3234
+ mutation(walletDoc);
3235
+ server.storage.db.collection(Storage.collections.WALLETS).replaceOne(
3236
+ { id: wallet.id },
3237
+ walletDoc,
3238
+ cb
3239
+ );
3240
+ }
3241
+ );
3242
+ }
3243
+
3244
+ it('should return NOT_AUTHORIZED when the target copayer is missing from the wallet', function(done) {
3245
+ mutateWalletDoc(doc => {
3246
+ doc.copayers = doc.copayers.filter(c => c.id !== opts.copayerId);
3247
+ }, (err) => {
3248
+ should.not.exist(err);
3249
+ snapshotState((err, before) => {
3250
+ should.not.exist(err);
3251
+ ws.addAccess(opts, (err, res) => {
3252
+ err.should.be.instanceof(ClientError);
3253
+ err.code.should.equal('NOT_AUTHORIZED');
3254
+ should.not.exist(res);
3255
+ snapshotState((err, after) => {
3256
+ should.not.exist(err);
3257
+ // The rejected attempt must not have persisted anything: no new access key, no other state change.
3258
+ after.should.deep.equal(before);
3259
+ done();
3260
+ });
3261
+ });
3262
+ });
3263
+ });
3264
+ });
3265
+
3266
+ it('should return NOT_AUTHORIZED when the target copayer has no xPubKey', function(done) {
3267
+ mutateWalletDoc(doc => {
3268
+ delete doc.copayers[0].xPubKey;
3269
+ }, (err) => {
3270
+ should.not.exist(err);
3271
+ snapshotState((err, before) => {
3272
+ should.not.exist(err);
3273
+ ws.addAccess(opts, (err, res) => {
3274
+ err.should.be.instanceof(ClientError);
3275
+ err.code.should.equal('NOT_AUTHORIZED');
3276
+ should.not.exist(res);
3277
+ snapshotState((err, after) => {
3278
+ should.not.exist(err);
3279
+ // The rejected attempt must not have persisted anything: no new access key, no other state change.
3280
+ after.should.deep.equal(before);
3281
+ done();
3282
+ });
3283
+ });
3284
+ });
3285
+ });
3286
+ });
3287
+
3288
+ it('should return NOT_AUTHORIZED when the target copayer has a malformed xPubKey', function(done) {
3289
+ mutateWalletDoc(doc => {
3290
+ doc.copayers[0].xPubKey = 'invalid';
3291
+ }, (err) => {
3292
+ should.not.exist(err);
3293
+ snapshotState((err, before) => {
3294
+ should.not.exist(err);
3295
+ ws.addAccess(opts, (err, res) => {
3296
+ err.should.be.instanceof(ClientError);
3297
+ err.code.should.equal('NOT_AUTHORIZED');
3298
+ should.not.exist(res);
3299
+ snapshotState((err, after) => {
3300
+ should.not.exist(err);
3301
+ // The rejected attempt must not have persisted anything: no new access key, no other state change.
3302
+ after.should.deep.equal(before);
3303
+ done();
3304
+ });
3305
+ });
3306
+ });
3307
+ });
3308
+ });
3309
+ });
2808
3310
  });
2809
3311
 
2810
3312
  describe('#getBalance', function() {
@@ -9030,10 +9532,238 @@ describe('Wallet service', function() {
9030
9532
  });
9031
9533
 
9032
9534
  it.skip('should get accepted/rejected transaction proposal', function(done) { });
9033
-
9535
+
9034
9536
  it.skip('should get broadcasted transaction proposal', function(done) { });
9035
9537
  });
9036
9538
 
9539
+ describe('#getTxByHash', function() {
9540
+ let serverA: WalletService;
9541
+ let walletA: Model.Wallet;
9542
+ let serverB: WalletService;
9543
+ let txp;
9544
+
9545
+ beforeEach(async function() {
9546
+ ({ server: serverA, wallet: walletA } = await helpers.createAndJoinWallet(1, 1));
9547
+ await helpers.stubUtxos(serverA, walletA, 1);
9548
+ const txOpts = {
9549
+ outputs: [{
9550
+ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7',
9551
+ amount: 0.5e8
9552
+ }],
9553
+ feePerKb: 100e2,
9554
+ message: 'some message',
9555
+ };
9556
+ txp = await helpers.createAndPublishTx(serverA, txOpts, TestData.copayers[0].privKey_1H_0);
9557
+ should.exist(txp);
9558
+
9559
+ const signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey_44H_0H_0H);
9560
+ txp = await util.promisify(serverA.signTx).call(serverA, {
9561
+ txProposalId: txp.id,
9562
+ signatures,
9563
+ });
9564
+ should.exist(txp.txid);
9565
+
9566
+ ({ server: serverB } = await helpers.createAndJoinWallet(1, 1, { offset: 1 }));
9567
+ });
9568
+
9569
+ it('should not disclose another wallet\'s transaction proposal by hash', function(done) {
9570
+ serverB.getTxByHash({
9571
+ txid: txp.txid
9572
+ }, function(err, res) {
9573
+ should.exist(err);
9574
+ should.not.exist(res);
9575
+ err.should.be.instanceof(ClientError);
9576
+ err.code.should.equal('TX_NOT_FOUND');
9577
+ err.message.should.equal('Transaction proposal not found');
9578
+ // Belt-and-suspenders: confirm the error itself carries none of
9579
+ // wallet A's data (no walletId/creatorId/raw leaking via the error).
9580
+ JSON.stringify(err).should.not.include(walletA.id);
9581
+ done();
9582
+ });
9583
+ });
9584
+
9585
+ it('should get own transaction proposal by hash, unchanged from before the fix', function(done) {
9586
+ serverA.editTxNote({
9587
+ txid: txp.txid,
9588
+ body: 'a note from wallet A'
9589
+ }, function(err) {
9590
+ should.not.exist(err);
9591
+ serverA.getTxByHash({
9592
+ txid: txp.txid
9593
+ }, function(err, res) {
9594
+ should.not.exist(err);
9595
+ should.exist(res);
9596
+ res.id.should.equal(txp.id);
9597
+ res.walletId.should.equal(walletA.id);
9598
+ res.txid.should.equal(txp.txid);
9599
+ should.exist(res.raw);
9600
+ should.exist(res.note);
9601
+ res.note.body.should.equal('a note from wallet A');
9602
+ done();
9603
+ });
9604
+ });
9605
+ });
9606
+
9607
+ it('should return the identical not-found error for a foreign txid and an unknown txid', function(done) {
9608
+ serverB.getTxByHash({
9609
+ txid: txp.txid
9610
+ }, function(errForeign, resForeign) {
9611
+ should.exist(errForeign);
9612
+ should.not.exist(resForeign);
9613
+ serverB.getTxByHash({
9614
+ txid: helpers.randomTXID()
9615
+ }, function(errUnknown, resUnknown) {
9616
+ should.exist(errUnknown);
9617
+ should.not.exist(resUnknown);
9618
+ errForeign.code.should.equal(errUnknown.code);
9619
+ errForeign.message.should.equal(errUnknown.message);
9620
+ errForeign.code.should.equal('TX_NOT_FOUND');
9621
+ done();
9622
+ });
9623
+ });
9624
+ });
9625
+
9626
+ it('should return a bad-request error when txid is missing', function(done) {
9627
+ serverA.getTxByHash({}, function(err, res) {
9628
+ should.exist(err);
9629
+ should.not.exist(res);
9630
+ err.should.be.instanceof(ClientError);
9631
+ err.code.should.equal('BADREQUEST');
9632
+ err.message.should.equal('Required argument: txid missing.');
9633
+ done();
9634
+ });
9635
+ });
9636
+ });
9637
+
9638
+ describe('GET /v1/txproposalsbyhash/:id/ (HTTP route)', function() {
9639
+ // Exercises the real Express route (registerTransactionRoutes ->
9640
+ // getServerWithAuth -> WalletService.getInstanceWithAuth), not just the
9641
+ // service method, so route wiring, request-auth plumbing, and HTTP
9642
+ // status/body serialization are covered, not only the underlying
9643
+ // service logic already tested above. Only the cryptographic signature
9644
+ // check is stubbed to return true
9645
+ const testHost = 'http://127.0.0.1';
9646
+ let testPort: number;
9647
+ let httpServer;
9648
+ let verifyStub;
9649
+ let walletA: Model.Wallet;
9650
+ let walletB: Model.Wallet;
9651
+ let txp;
9652
+
9653
+ beforeEach(async function() {
9654
+ httpServer = undefined;
9655
+ verifyStub = undefined;
9656
+
9657
+ let serverA: WalletService;
9658
+ let serverB: WalletService;
9659
+ ({ server: serverA, wallet: walletA } = await helpers.createAndJoinWallet(1, 1));
9660
+ await helpers.stubUtxos(serverA, walletA, 1);
9661
+ const txOpts = {
9662
+ outputs: [{
9663
+ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7',
9664
+ amount: 0.5e8
9665
+ }],
9666
+ feePerKb: 100e2,
9667
+ message: 'some message',
9668
+ };
9669
+ txp = await helpers.createAndPublishTx(serverA, txOpts, TestData.copayers[0].privKey_1H_0);
9670
+ const signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey_44H_0H_0H);
9671
+ txp = await util.promisify(serverA.signTx).call(serverA, {
9672
+ txProposalId: txp.id,
9673
+ signatures,
9674
+ });
9675
+ should.exist(txp.txid);
9676
+
9677
+ ({ server: serverB, wallet: walletB } = await helpers.createAndJoinWallet(1, 1, { offset: 1 }));
9678
+
9679
+ verifyStub = sinon.stub(WalletService.prototype, '_verifySignature').returns(true);
9680
+
9681
+ const app = new ExpressApp();
9682
+ httpServer = new http.Server(app.app);
9683
+ await util.promisify(app.start).call(app, {
9684
+ storage: helpers.getStorage(),
9685
+ blockchainExplorer: helpers.getBlockchainExplorer(),
9686
+ request: sinon.stub(),
9687
+ disableLogs: true,
9688
+ basePath: config.basePath
9689
+ });
9690
+
9691
+ await new Promise<void>((resolve, reject) => {
9692
+ httpServer.once('error', reject);
9693
+ httpServer.listen(0, '127.0.0.1', () => {
9694
+ httpServer.off('error', reject);
9695
+ const address = httpServer.address();
9696
+ if (!address || typeof address === 'string') {
9697
+ return reject(new Error('HTTP test server did not bind to a TCP port'));
9698
+ }
9699
+ testPort = address.port;
9700
+ resolve();
9701
+ });
9702
+ });
9703
+ });
9704
+
9705
+ afterEach(function(done) {
9706
+ if (verifyStub) {
9707
+ verifyStub.restore();
9708
+ verifyStub = undefined;
9709
+ }
9710
+ if (!httpServer?.listening) {
9711
+ httpServer = undefined;
9712
+ return done();
9713
+ }
9714
+ httpServer.close(err => {
9715
+ httpServer = undefined;
9716
+ done(err);
9717
+ });
9718
+ });
9719
+
9720
+ function getByHash(copayerId, txid, cb) {
9721
+ request({
9722
+ method: 'GET',
9723
+ url: testHost + ':' + testPort + config.basePath + '/v1/txproposalsbyhash/' + txid + '/',
9724
+ headers: {
9725
+ 'x-identity': copayerId,
9726
+ 'x-signature': 'stubbed'
9727
+ },
9728
+ json: true
9729
+ }, (err, res, body) => cb(err, res, body));
9730
+ }
9731
+
9732
+ it('foreign wallet gets HTTP 400 TX_NOT_FOUND with no owner data in the body', function(done) {
9733
+ getByHash(walletB.copayers[0].id, txp.txid, (err, res, body) => {
9734
+ should.not.exist(err);
9735
+ res.statusCode.should.equal(400);
9736
+ body.code.should.equal('TX_NOT_FOUND');
9737
+ JSON.stringify(body).should.not.include(walletA.id);
9738
+ done();
9739
+ });
9740
+ });
9741
+
9742
+ it('owner wallet gets HTTP 200 with the full proposal, unchanged', function(done) {
9743
+ getByHash(walletA.copayers[0].id, txp.txid, (err, res, body) => {
9744
+ should.not.exist(err);
9745
+ res.statusCode.should.equal(200);
9746
+ body.walletId.should.equal(walletA.id);
9747
+ body.txid.should.equal(txp.txid);
9748
+ should.exist(body.raw);
9749
+ done();
9750
+ });
9751
+ });
9752
+
9753
+ it('foreign and unknown txids produce an identical HTTP 400 TX_NOT_FOUND body', function(done) {
9754
+ getByHash(walletB.copayers[0].id, txp.txid, (err, resForeign, bodyForeign) => {
9755
+ should.not.exist(err);
9756
+ getByHash(walletB.copayers[0].id, helpers.randomTXID(), (err, resUnknown, bodyUnknown) => {
9757
+ should.not.exist(err);
9758
+ resForeign.statusCode.should.equal(resUnknown.statusCode);
9759
+ bodyForeign.code.should.equal(bodyUnknown.code);
9760
+ bodyForeign.code.should.equal('TX_NOT_FOUND');
9761
+ done();
9762
+ });
9763
+ });
9764
+ });
9765
+ });
9766
+
9037
9767
  describe('#getTxs', function() {
9038
9768
  let server: WalletService;
9039
9769
  let wallet: Model.Wallet;