From fac27439fb9fd84fa3001443e28831fced58dabc Mon Sep 17 00:00:00 2001 From: Sanket Doshi Date: Tue, 10 Sep 2024 15:50:07 +0530 Subject: [PATCH] feat(sdk-core): add bulkAcceptShare function This function will be used to accept bulk wallet shares. Ticket: CS-3707 TICKET: CS-3707 --- examples/ts/bulk-accept-shares.ts | 33 ++++ modules/bitgo/test/v2/unit/wallets.ts | 181 +++++++++++++++++- modules/sdk-core/src/bitgo/wallet/iWallets.ts | 24 ++- modules/sdk-core/src/bitgo/wallet/wallets.ts | 93 +++++++++ 4 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 examples/ts/bulk-accept-shares.ts diff --git a/examples/ts/bulk-accept-shares.ts b/examples/ts/bulk-accept-shares.ts new file mode 100644 index 0000000000..dede076c72 --- /dev/null +++ b/examples/ts/bulk-accept-shares.ts @@ -0,0 +1,33 @@ +/** + * Accept multiple wallet shares. + * This makes use of the convenience function wallets().bulkAcceptShare() + * + * This tool will help you see how to use the BitGo API to easily list your + * BitGo wallets. + * + * Copyright 2022, BitGo, Inc. All Rights Reserved. + */ +import { BitGoAPI } from '@bitgo/sdk-api'; +import { Tltc } from '@bitgo/sdk-coin-ltc'; +require('dotenv').config({ path: '../../.env' }); + +const bitgo = new BitGoAPI({ + accessToken: process.env.TESTNET_ACCESS_TOKEN, + env: 'test', +}); + +const coin = 'tltc'; +bitgo.register(coin, Tltc.createInstance); + +const walletShareIds = ['']; // add the shareIds which needs to be accepted +const userLoginPassword = ''; // add the user login password + +async function main() { + const acceptShare = await bitgo.coin(coin).wallets().bulkAcceptShare({ + walletShareIds: walletShareIds, + userLoginPassword: userLoginPassword, + }); + console.dir(acceptShare); +} + +main().catch((e) => console.error(e)); diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index e7ff821cda..6d79ae8706 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -6,7 +6,7 @@ import * as nock from 'nock'; import * as sinon from 'sinon'; import * as should from 'should'; import * as _ from 'lodash'; - +import * as utxoLib from '@bitgo/utxo-lib'; import { TestBitGo } from '@bitgo/sdk-test'; import { BlsUtils, @@ -18,8 +18,11 @@ import { GenerateWalletOptions, Wallet, isWalletWithKeychains, - BulkWalletShareOptions, OptionalKeychainEncryptedKey, + decryptKeychainPrivateKey, + makeRandomKey, + getSharedSecret, + BulkWalletShareOptions, KeychainWithEncryptedPrv, } from '@bitgo/sdk-core'; import { BitGo } from '../../../src'; @@ -1430,6 +1433,180 @@ describe('V2 Wallets:', function () { await wallets.acceptShare({ walletShareId: shareId }); acceptShareNock.done(); }); + + describe('bulkAcceptShare', function () { + afterEach(function () { + nock.cleanAll(); + nock.pendingMocks().length.should.equal(0); + sinon.restore(); + }); + + it('should throw validation error for userPassword empty string', async () => { + await wallets + .bulkAcceptShare({ walletShareIds: [], userLoginPassword: '' }) + .should.rejectedWith('Missing parameter: userLoginPassword'); + }); + + it('should throw assertion error for empty walletShareIds', async () => { + await wallets + .bulkAcceptShare({ walletShareIds: [], userLoginPassword: 'dummy@123' }) + .should.rejectedWith('no walletShareIds are passed'); + }); + + it('should throw error for no valid wallet shares', async () => { + sinon.stub(Wallets.prototype, 'listSharesV2').resolves({ + incoming: [ + { + id: '66a229dbdccdcfb95b44fc2745a60bd4', + coin: 'tsol', + walletLabel: 'testing', + fromUser: 'dummyFromUser', + toUser: 'dummyToUser', + wallet: 'dummyWalletId', + permissions: ['spend'], + state: 'active', + }, + ], + outgoing: [], + }); + await wallets + .bulkAcceptShare({ + walletShareIds: ['66a229dbdccdcfb95b44fc2745a60bd1'], + userLoginPassword: 'dummy@123', + }) + .should.rejectedWith('invalid wallet shares provided'); + }); + + it('should throw error for no valid walletShares with keychain', async () => { + sinon.stub(Wallets.prototype, 'listSharesV2').resolves({ + incoming: [ + { + id: '66a229dbdccdcfb95b44fc2745a60bd4', + coin: 'tsol', + walletLabel: 'testing', + fromUser: 'dummyFromUser', + toUser: 'dummyToUser', + wallet: 'dummyWalletId', + permissions: ['spend'], + state: 'active', + }, + ], + outgoing: [], + }); + + await wallets + .bulkAcceptShare({ + walletShareIds: ['66a229dbdccdcfb95b44fc2745a60bd4'], + userLoginPassword: 'dummy@123', + }) + .should.rejectedWith('invalid wallet shares provided'); + }); + + it('should throw error for ecdh keychain undefined', async () => { + sinon.stub(Wallets.prototype, 'listSharesV2').resolves({ + incoming: [ + { + id: '66a229dbdccdcfb95b44fc2745a60bd4', + coin: 'tsol', + walletLabel: 'testing', + fromUser: 'dummyFromUser', + toUser: 'dummyToUser', + wallet: 'dummyWalletId', + permissions: ['spend'], + state: 'active', + keychain: { + pub: 'pub', + toPubKey: 'toPubKey', + fromPubKey: 'fromPubKey', + encryptedPrv: 'encryptedPrv', + path: 'path', + }, + }, + ], + outgoing: [], + }); + sinon.stub(bitgo, 'getECDHKeychain').resolves({ + prv: 'private key', + }); + + await wallets + .bulkAcceptShare({ + walletShareIds: ['66a229dbdccdcfb95b44fc2745a60bd4'], + userLoginPassword: 'dummy@123', + }) + .should.rejectedWith('encryptedXprv was not found on sharing keychain'); + }); + + it('should successfully accept share', async () => { + const fromUserPrv = Math.random(); + const walletPassphrase = 'bitgo1234'; + const keychainTest: OptionalKeychainEncryptedKey = { + encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }), + }; + const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); + if (!userPrv) { + throw new Error('Unable to decrypt user keychain'); + } + + const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex')); + const path = 'm/999999/1/1'; + const pubkey = toKeychain.derivePath(path).publicKey.toString('hex'); + + const eckey = makeRandomKey(); + const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex'); + const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv }); + nock(bgUrl) + .get('/api/v2/walletshares') + .reply(200, { + incoming: [ + { + id: '66a229dbdccdcfb95b44fc2745a60bd4', + isUMSInitiated: true, + keychain: { + path: path, + fromPubKey: eckey.publicKey.toString('hex'), + encryptedPrv: newEncryptedPrv, + toPubKey: pubkey, + pub: pubkey, + }, + }, + ], + }); + nock(bgUrl) + .put('/api/v2/walletshares/accept') + .reply(200, { + acceptedWalletShares: [ + { + walletShareId: '66a229dbdccdcfb95b44fc2745a60bd4', + }, + ], + }); + + const myEcdhKeychain = await bitgo.keychains().create(); + sinon.stub(bitgo, 'getECDHKeychain').resolves({ + encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }), + }); + + const prvKey = bitgo.decrypt({ + password: walletPassphrase, + input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }), + }); + sinon.stub(bitgo, 'decrypt').returns(prvKey); + sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret'); + + const share = await wallets.bulkAcceptShare({ + walletShareIds: ['66a229dbdccdcfb95b44fc2745a60bd4'], + userLoginPassword: walletPassphrase, + }); + assert.deepEqual(share, { + acceptedWalletShares: [ + { + walletShareId: '66a229dbdccdcfb95b44fc2745a60bd4', + }, + ], + }); + }); + }); }); describe('createBulkKeyShares tests', () => { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index e5137ad583..44dda4a262 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -2,7 +2,7 @@ import * as t from 'io-ts'; import { IRequestTracer } from '../../api'; import { KeychainsTriplet, LightningKeychainsTriplet } from '../baseCoin'; -import { IWallet, PaginationOptions } from './iWallet'; +import { IWallet, PaginationOptions, WalletShare } from './iWallet'; import { Wallet } from './wallet'; export interface WalletWithKeychains extends KeychainsTriplet { @@ -107,6 +107,17 @@ export interface AcceptShareOptions { newWalletPassphrase?: string; } +export interface BulkAcceptShareOptions { + walletShareIds: string[]; + userLoginPassword: string; + newWalletPassphrase?: string; +} + +export interface AcceptShareOptionsRequest { + walletShareId: string; + encryptedPrv: string; +} + export interface AddWalletOptions { coinSpecific?: any; enterprise?: string; @@ -157,6 +168,15 @@ export interface ListWalletOptions extends PaginationOptions { allTokens?: boolean; } +export interface WalletShares { + incoming: WalletShare[]; // WalletShares that the user has to accept + outgoing: WalletShare[]; // WalletShares that the user has created +} + +export interface AcceptShareResponse { + walletShareId: string; +} + export interface IWallets { get(params?: GetWalletOptions): Promise; list(params?: ListWalletOptions): Promise<{ wallets: IWallet[] }>; @@ -171,4 +191,6 @@ export interface IWallets { getWallet(params?: GetWalletOptions): Promise; getWalletByAddress(params?: GetWalletByAddressOptions): Promise; getTotalBalances(params?: Record): Promise; + bulkAcceptShare(params: BulkAcceptShareOptions): Promise; + listSharesV2(): Promise; } diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 1aab8546b5..be9e06194d 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -16,7 +16,10 @@ import { AddKeychainOptions, Keychain, KeyIndices } from '../keychain'; import { decodeOrElse, promiseProps, RequestTracer } from '../utils'; import { AcceptShareOptions, + AcceptShareOptionsRequest, + AcceptShareResponse, AddWalletOptions, + BulkAcceptShareOptions, GenerateBaseMpcWalletOptions, GenerateLightningWalletOptions, GenerateLightningWalletOptionsCodec, @@ -29,8 +32,10 @@ import { LightningWalletWithKeychains, ListWalletOptions, UpdateShareOptions, + WalletShares, WalletWithKeychains, } from './iWallets'; +import { WalletShare } from './iWallet'; import { Wallet } from './wallet'; import { TssSettings } from '@bitgo/public-types'; @@ -607,6 +612,14 @@ export class Wallets implements IWallets { return await this.bitgo.get(this.baseCoin.url('/walletshare')).result(); } + /** + * List the user's wallet shares v2 + * @returns {Promise} + */ + async listSharesV2(): Promise { + return await this.bitgo.get(this.bitgo.url('/walletshares', 2)).result(); + } + /** * Gets a wallet share information, including the encrypted sharing keychain. requires unlock if keychain is present. * @param params @@ -633,6 +646,20 @@ export class Wallets implements IWallets { .result(); } + /** + * Bulk accept wallet shares + * @param params AcceptShareOptionsRequest[] + * @returns {Promise} + */ + async bulkAcceptShareRequest(params: AcceptShareOptionsRequest[]): Promise { + return await this.bitgo + .put(this.bitgo.url('/walletshares/accept', 2)) + .send({ + keysForWalletShares: params, + }) + .result(); + } + /** * Resend a wallet share invitation email * @param params @@ -811,6 +838,72 @@ export class Wallets implements IWallets { return this.updateShare(updateParams); } + /** + * Bulk Accept wallet shares, adding the wallets to the user's list + * Needs a user's password to decrypt the shared key + * + * @param params BulkAcceptShareOptions + * @param params.walletShareId - array of the wallet shares to accept + * @param params.userPassword - user's password to decrypt the shared wallet key + * @param params.newWalletPassphrase - new wallet passphrase for saving the shared wallet prv. + * If left blank then the user's login password is used. + * + *@returns {Promise} + */ + async bulkAcceptShare(params: BulkAcceptShareOptions): Promise { + common.validateParams(params, ['userLoginPassword'], ['newWalletPassphrase']); + assert(params.walletShareIds.length > 0, 'no walletShareIds are passed'); + + const allWalletShares = await this.listSharesV2(); + const walletShareMap = allWalletShares.incoming.reduce( + (map: { [key: string]: WalletShare }, share) => ({ ...map, [share.id]: share }), + {} + ); + + const walletShares = params.walletShareIds + .map((walletShareId) => walletShareMap[walletShareId]) + .filter((walletShare) => walletShare && walletShare.keychain); + if (!walletShares.length) { + throw new Error('invalid wallet shares provided'); + } + const sharingKeychain = await this.bitgo.getECDHKeychain(); + if (_.isUndefined(sharingKeychain.encryptedXprv)) { + throw new Error('encryptedXprv was not found on sharing keychain'); + } + + sharingKeychain.prv = this.bitgo.decrypt({ + password: params.userLoginPassword, + input: sharingKeychain.encryptedXprv, + }); + const newWalletPassphrase = params.newWalletPassphrase || params.userLoginPassword; + const keysForWalletShares = walletShares.flatMap((walletShare) => { + if (!walletShare.keychain) { + return []; + } + const secret = getSharedSecret( + bip32.fromBase58(sharingKeychain.prv).derivePath(sanitizeLegacyPath(walletShare.keychain.path)), + Buffer.from(walletShare.keychain.fromPubKey, 'hex') + ).toString('hex'); + + const decryptedSharedWalletPrv = this.bitgo.decrypt({ + password: secret, + input: walletShare.keychain.encryptedPrv, + }); + const newEncryptedPrv = this.bitgo.encrypt({ + password: newWalletPassphrase, + input: decryptedSharedWalletPrv, + }); + return [ + { + walletShareId: walletShare.id, + encryptedPrv: newEncryptedPrv, + }, + ]; + }); + + return this.bulkAcceptShareRequest(keysForWalletShares); + } + /** * Get a wallet by its ID * @param params