diff --git a/modules/bitgo/test/unit/bitgo.ts b/modules/bitgo/test/unit/bitgo.ts index 6e1374bcd9..d8e0251cbf 100644 --- a/modules/bitgo/test/unit/bitgo.ts +++ b/modules/bitgo/test/unit/bitgo.ts @@ -237,41 +237,85 @@ describe('BitGo Prototype Methods', function () { 'xpub661MyMwAqRbcEusRjkJ64BXgR8ddYsXbuDJfbRc3eZcZVEa2ygswDiFZQpHFsA5N211YDvi2N898h4KrcXcfsR8PLhjJaPUwCUqg1ptBBHN'; const passwords = ['mickey', 'mouse', 'donald', 'duck']; - it('should fail to split secret with wrong m', () => { - (() => - bitgo.splitSecret({ + it('should fail to split secret with wrong m', async () => { + await bitgo + .splitSecretAsync({ seed, passwords: ['abc'], m: 0, - })).should.throw('m must be a positive integer greater than or equal to 2'); + }) + .should.be.rejectedWith('m must be a positive integer greater than or equal to 2'); }); - it('should fail to split secret with bad password count', () => { - (() => - bitgo.splitSecret({ + it('should fail to split secret with bad password count', async () => { + await bitgo + .splitSecretAsync({ seed, passwords: ['abc'], m: 2, - })).should.throw('passwords array length cannot be less than m'); + }) + .should.be.rejectedWith('passwords array length cannot be less than m'); }); - it('should split and fail to reconstitute secret with bad passwords', () => { - const splitSecret = bitgo.splitSecret({ seed, passwords: passwords, m: 3 }); + it('should split and fail to reconstitute secret with bad passwords', async () => { + const splitSecret = await bitgo.splitSecretAsync({ seed, passwords: passwords, m: 3 }); const shards = _.at(splitSecret.seedShares, [0, 2]); const subsetPasswords = _.at(passwords, [0, 3]); - (() => - bitgo.reconstituteSecret({ + await bitgo + .reconstituteSecretAsync({ shards, passwords: subsetPasswords, xpub, - } as any)).should.throw(/ccm: tag doesn't match/); + } as any) + .should.be.rejectedWith('incorrect password'); + }); + + it('should split and reconstitute secret', async () => { + const splitSecret = await bitgo.splitSecret({ seed, passwords: passwords, m: 2 }); + const shards = _.at(splitSecret.seedShares, [0, 2]); + const subsetPasswords = _.at(passwords, [0, 2]); + const reconstitutedSeed = await bitgo.reconstituteSecret({ shards, passwords: subsetPasswords }); + reconstitutedSeed.seed.should.equal(seed); + reconstitutedSeed.xpub.should.equal( + 'xpub661MyMwAqRbcEusRjkJ64BXgR8ddYsXbuDJfbRc3eZcZVEa2ygswDiFZQpHFsA5N211YDvi2N898h4KrcXcfsR8PLhjJaPUwCUqg1ptBBHN' + ); + reconstitutedSeed.xprv.should.equal( + 'xprv9s21ZrQH143K2Rnxdim5h3aws6o99QokXzP4o3CS6E5acSEtS9Zgfuw5ZWujhTHTWEAZDfmP3yxA1Ccn6myVkGEpRrT4xWgaEpoW7YiBAtC' + ); + }); + + it('should split and incorrectly verify secret', async () => { + const splitSecret = await bitgo.splitSecret({ seed, passwords: passwords, m: 3 }); + const isValid = await bitgo.verifyShards({ shards: splitSecret.seedShares, passwords, m: 2 } as any); + isValid.should.equal(false); + }); + + it('should split and verify secret', async () => { + const splitSecret = await bitgo.splitSecret({ seed, passwords: passwords, m: 2 }); + const isValid = await bitgo.verifyShards({ shards: splitSecret.seedShares, passwords, m: 2, xpub }); + isValid.should.equal(true); + }); + + it('should split and verify secret with many parts', async () => { + const allPws = ['0', '1', '2', '3', '4', '5', '6', '7']; + const splitSecret = await bitgo.splitSecret({ seed, passwords: allPws, m: 3 }); + const isValid = await bitgo.verifyShards({ shards: splitSecret.seedShares, passwords: allPws, m: 3, xpub }); + isValid.should.equal(true); }); + }); - it('should split and reconstitute secret', () => { - const splitSecret = bitgo.splitSecret({ seed, passwords: passwords, m: 2 }); + describe('Shamir Secret Sharing Async', () => { + const bitgo = TestBitGo.decorate(BitGo); + const seed = '8cc57dac9cdae42bf7848a2d12f2874d31eca1f9de8fe3f8fa13e7857b545d59'; + const xpub = + 'xpub661MyMwAqRbcEusRjkJ64BXgR8ddYsXbuDJfbRc3eZcZVEa2ygswDiFZQpHFsA5N211YDvi2N898h4KrcXcfsR8PLhjJaPUwCUqg1ptBBHN'; + const passwords = ['mickey', 'mouse', 'donald', 'duck']; + + it('should split and reconstitute secret using async methods', async () => { + const splitSecret = await bitgo.splitSecretAsync({ seed, passwords: passwords, m: 2 }); const shards = _.at(splitSecret.seedShares, [0, 2]); const subsetPasswords = _.at(passwords, [0, 2]); - const reconstitutedSeed = bitgo.reconstituteSecret({ shards, passwords: subsetPasswords }); + const reconstitutedSeed = await bitgo.reconstituteSecretAsync({ shards, passwords: subsetPasswords }); reconstitutedSeed.seed.should.equal(seed); reconstitutedSeed.xpub.should.equal( 'xpub661MyMwAqRbcEusRjkJ64BXgR8ddYsXbuDJfbRc3eZcZVEa2ygswDiFZQpHFsA5N211YDvi2N898h4KrcXcfsR8PLhjJaPUwCUqg1ptBBHN' @@ -281,22 +325,22 @@ describe('BitGo Prototype Methods', function () { ); }); - it('should split and incorrectly verify secret', () => { - const splitSecret = bitgo.splitSecret({ seed, passwords: passwords, m: 3 }); - const isValid = bitgo.verifyShards({ shards: splitSecret.seedShares, passwords, m: 2 } as any); + it('should split and incorrectly verify secret using async methods', async () => { + const splitSecret = await bitgo.splitSecretAsync({ seed, passwords: passwords, m: 3 }); + const isValid = await bitgo.verifyShardsAsync({ shards: splitSecret.seedShares, passwords, m: 2 } as any); isValid.should.equal(false); }); - it('should split and verify secret', () => { - const splitSecret = bitgo.splitSecret({ seed, passwords: passwords, m: 2 }); - const isValid = bitgo.verifyShards({ shards: splitSecret.seedShares, passwords, m: 2, xpub }); + it('should split and verify secret using async methods', async () => { + const splitSecret = await bitgo.splitSecretAsync({ seed, passwords: passwords, m: 2 }); + const isValid = await bitgo.verifyShardsAsync({ shards: splitSecret.seedShares, passwords, m: 2, xpub }); isValid.should.equal(true); }); - it('should split and verify secret with many parts', () => { + it('should split and verify secret with many parts using async methods', async () => { const allPws = ['0', '1', '2', '3', '4', '5', '6', '7']; - const splitSecret = bitgo.splitSecret({ seed, passwords: allPws, m: 3 }); - const isValid = bitgo.verifyShards({ shards: splitSecret.seedShares, passwords: allPws, m: 3, xpub }); + const splitSecret = await bitgo.splitSecretAsync({ seed, passwords: allPws, m: 3 }); + const isValid = await bitgo.verifyShardsAsync({ shards: splitSecret.seedShares, passwords: allPws, m: 3, xpub }); isValid.should.equal(true); }); }); @@ -436,7 +480,22 @@ describe('BitGo Prototype Methods', function () { requestHeaders.hmac.should.equal('6de77d5a5446a3e5649456c11480706a71071b15639c3c787af65bdb02ecf1ec'); }); - it('should correctly handle authentication response', () => { + it('should correctly handle authentication response', async () => { + const responseJson = { + encryptedToken: + '{"iv":"EqxVaGTLY4naAYkuBaTz0w==","v":1,"iter":1000,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"4S4dBYcgL4s=","ct":"FgBRJljb8iSYxnAjMi4Qotr7sTKbSmWnlfHZShMSi8YeeE3kiS8bpHNUwAPhY8tgouh3UsEwrJnY+54MvqFD7yd19pG1V4CVssr8"}', + derivationPath: 'm/999999/104490948/173846667', + encryptedECDHXprv: + '{"iv":"QKHEF2GNcwOJwy6+pwANRA==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"W2sVFvXDlOw=","ct":"8BTCqS25X37kLzmzQdGenhXH6znn9qEmkszAeS8kLnRdqKSiUiC7bTAVgg/Np5yrV7F7Jyiq+MTpVT76EoUT+PMJzArv0gUQKC2JPB3JuVKeAAVWBQmhWfkEwRfyv4hq4WMxwZtocwBqThvd2pJm9HE51GX4/Wo="}', + }; + const parsedAuthenticationData = await bitgo.handleTokenIssuance(responseJson, 'test@bitgo.com'); + parsedAuthenticationData.token.should.equal(token); + parsedAuthenticationData.ecdhXprv.should.equal( + 'xprv9s21ZrQH143K3si1bKGp7KqgCQv39ttQ7aUwWzVdytgHd8HtDCHyEp14mxfhiT3qHTq4BaSrA7uUkG6AJTfPJBsRu63drvBqYuMZyTxepH7' + ); + }); + + it('should correctly handle authentication response using handleTokenIssuanceAsync', async () => { const responseJson = { encryptedToken: '{"iv":"EqxVaGTLY4naAYkuBaTz0w==","v":1,"iter":1000,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"4S4dBYcgL4s=","ct":"FgBRJljb8iSYxnAjMi4Qotr7sTKbSmWnlfHZShMSi8YeeE3kiS8bpHNUwAPhY8tgouh3UsEwrJnY+54MvqFD7yd19pG1V4CVssr8"}', @@ -444,7 +503,7 @@ describe('BitGo Prototype Methods', function () { encryptedECDHXprv: '{"iv":"QKHEF2GNcwOJwy6+pwANRA==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"W2sVFvXDlOw=","ct":"8BTCqS25X37kLzmzQdGenhXH6znn9qEmkszAeS8kLnRdqKSiUiC7bTAVgg/Np5yrV7F7Jyiq+MTpVT76EoUT+PMJzArv0gUQKC2JPB3JuVKeAAVWBQmhWfkEwRfyv4hq4WMxwZtocwBqThvd2pJm9HE51GX4/Wo="}', }; - const parsedAuthenticationData = bitgo.handleTokenIssuance(responseJson, 'test@bitgo.com'); + const parsedAuthenticationData = await bitgo.handleTokenIssuanceAsync(responseJson, 'test@bitgo.com'); parsedAuthenticationData.token.should.equal(token); parsedAuthenticationData.ecdhXprv.should.equal( 'xprv9s21ZrQH143K3si1bKGp7KqgCQv39ttQ7aUwWzVdytgHd8HtDCHyEp14mxfhiT3qHTq4BaSrA7uUkG6AJTfPJBsRu63drvBqYuMZyTxepH7' diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index afedd60e70..d950e7c2a2 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -4272,8 +4272,8 @@ describe('V2 Wallets:', function () { }); const encryptPrvForUserStub = sinon - .stub(wallet, 'encryptPrvForUser') - .callsFake((prv, pubKey, userPubKey, path) => { + .stub(wallet, 'encryptPrvForUserAsync') + .callsFake(async (prv, pubKey, userPubKey, path) => { return { pub: pubKey, encryptedPrv: 'dummyEncryptedPrv', @@ -4328,6 +4328,80 @@ describe('V2 Wallets:', function () { }); }); + describe('downloadKeycardAsync', () => { + const localBitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + const walletData = { + id: '5b34252f1bf349930e34020a00000002', + coin: 'tbtc', + keys: [ + '5b3424f91bf349930e34017500000000', + '5b3424f91bf349930e34017600000000', + '5b3424f91bf349930e34017700000000', + ], + coinSpecific: {}, + multisigType: 'onchain', + type: 'hot', + }; + const tbtc = localBitgo.coin('tbtc'); + const wallet = new Wallet(localBitgo, tbtc, walletData); + + it('should throw when called in Node.js (no browser window)', async () => { + // In Node.js, accessing `window` throws ReferenceError; the method rejects. + await wallet.downloadKeycardAsync().should.be.rejected(); + }); + + it('downloadKeycard (sync) should throw when called in Node.js (no browser window)', () => { + should.throws(() => wallet.downloadKeycard()); + }); + }); + + describe('encryptPrvForUserAsync', () => { + const localBitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + const walletData = { + id: '5b34252f1bf349930e34020a00000001', + coin: 'tbtc', + keys: [ + '5b3424f91bf349930e34017500000000', + '5b3424f91bf349930e34017600000000', + '5b3424f91bf349930e34017700000000', + ], + coinSpecific: {}, + multisigType: 'onchain', + type: 'hot', + }; + const tbtc = localBitgo.coin('tbtc'); + const wallet = new Wallet(localBitgo, tbtc, walletData); + + before(function () { + nock('https://bitgo.fakeurl').persist().get('/api/v1/client/constants').reply(200, { ttl: 3600, constants: {} }); + localBitgo.initializeTestVars(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should encrypt prv for user and return the correct output shape', async () => { + const decryptedPrv = + 'xprv9s21ZrQH143K2fJ91S4BRsupcYrE6mmY96fcX5HkhoTrrwmwjd16Cn87cWinJjByrfpojjx7ezsJLx7TAKLT8m8hM5Kax9YcoxnBeJZ3t2k'; + const pub = + 'xpub661MyMwAqRbcF9Nc7TbBo1rZAagiWEVPWKbDKThNG8zqjk76HAKLkaSbTn6dK2dQPfuD7xjicxCZVWvj67fP5nQ9W7QURmoMVAX8m6jZsGp'; + // A valid 33-byte compressed EC point on secp256k1 + const userPubkey = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const path = 'm/999999/0/1'; + + sinon.stub(localBitgo, 'encryptAsync').resolves('encryptedPrvForUser'); + + const result = await wallet.encryptPrvForUserAsync(decryptedPrv, pub, userPubkey, path); + + result.should.have.property('pub', pub); + result.should.have.property('encryptedPrv', 'encryptedPrvForUser'); + result.should.have.property('fromPubKey').which.is.a.String(); + result.should.have.property('toPubKey', userPubkey); + result.should.have.property('path', path); + }); + }); + describe('List Wallets:', function () { it('should list wallets with skipReceiveAddress = true', async function () { const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 34ca3e6b80..b183b9afcc 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -783,6 +783,7 @@ export class BitGoAPI implements BitGoBase { } /** + * TODO: deprecate this function in favor of decryptKeysAsync once v2 encryption is default * Attempt to decrypt multiple wallet keys with the provided passphrase * @param {DecryptKeysOptions} params - Parameters object containing wallet key pairs and password * @param {Array<{walletId: string, encryptedPrv: string}>} params.walletIdEncryptedKeyPairs - Array of wallet ID and encrypted private key pairs @@ -833,6 +834,52 @@ export class BitGoAPI implements BitGoBase { return failedWalletIds; } + /** + * Async version of decryptKeys with v2 encrypt/decrypt support. + * @param params + */ + async decryptKeysAsync(params: DecryptKeysOptions): Promise { + params = params || {}; + if (!params.walletIdEncryptedKeyPairs) { + throw new Error('Missing parameter: walletIdEncryptedKeyPairs'); + } + + if (!params.password) { + throw new Error('Missing parameter: password'); + } + + if (!Array.isArray(params.walletIdEncryptedKeyPairs)) { + throw new Error('walletIdEncryptedKeyPairs must be an array'); + } + + if (params.walletIdEncryptedKeyPairs.length === 0) { + return []; + } + + const failedWalletIds: string[] = []; + + for (const keyPair of params.walletIdEncryptedKeyPairs) { + if (!keyPair.walletId || typeof keyPair.walletId !== 'string') { + throw new Error('each key pair must have a string walletId'); + } + + if (!keyPair.encryptedPrv || typeof keyPair.encryptedPrv !== 'string') { + throw new Error('each key pair must have a string encryptedPrv'); + } + + try { + await this.decryptAsync({ + input: keyPair.encryptedPrv, + password: params.password, + }); + } catch (error) { + failedWalletIds.push(keyPair.walletId); + } + } + + return failedWalletIds; + } + /** * Serialize this BitGo object to a JSON object. * @@ -987,7 +1034,7 @@ export class BitGoAPI implements BitGoBase { return await this.keychains().add({ source: 'ecdh', xpub: hdNode.neutered().toBase58(), - encryptedXprv: this.encrypt({ + encryptedXprv: await this.encryptAsync({ password: loginPassword, input: hdNode.toBase58(), }), @@ -1083,7 +1130,7 @@ export class BitGoAPI implements BitGoBase { throw new Error('Keychain needs encryptedXprv property'); } - const responseDetails = this.handleTokenIssuance(response.body, password); + const responseDetails = await this.handleTokenIssuanceAsync(response.body, password); this._token = responseDetails.token; this._ecdhXprv = responseDetails.ecdhXprv; @@ -1157,7 +1204,7 @@ export class BitGoAPI implements BitGoBase { } /** - * + * TODO: Deprecate this function in favor of handleTokenIssuanceAsync once v2 encryption is default. * @param responseBody Response body object * @param password Password for the symmetric decryption */ @@ -1225,6 +1272,75 @@ export class BitGoAPI implements BitGoBase { return response; } + /** + * Async version of handleTokenIssuance with v2 encrypt/decrypt support. + * @param responseBody Response body object + * @param password Password for the symmetric decryption + */ + async handleTokenIssuanceAsync(responseBody: TokenIssuanceResponse, password?: string): Promise { + // make sure the response body contains the necessary properties + common.validateParams(responseBody, ['derivationPath'], ['encryptedECDHXprv']); + + const environment = this._env; + const environmentConfig = common.Environments[environment]; + const serverXpub = environmentConfig.serverXpub; + let ecdhXprv = this._ecdhXprv; + if (!ecdhXprv) { + if (!password || !responseBody.encryptedECDHXprv) { + throw new Error('ecdhXprv property must be set or password and encrypted encryptedECDHXprv must be provided'); + } + try { + ecdhXprv = await this.decryptAsync({ + input: responseBody.encryptedECDHXprv, + password: password, + }); + } catch (e) { + e.errorCode = 'ecdh_xprv_decryption_failure'; + console.error('Failed to decrypt encryptedECDHXprv.'); + throw e; + } + } + + // construct HDNode objects for client's xprv and server's xpub + const clientHDNode = bip32.fromBase58(ecdhXprv); + const serverHDNode = bip32.fromBase58(serverXpub); + + // BIP32 derivation path is applied to both client and server master keys + const derivationPath = sanitizeLegacyPath(responseBody.derivationPath); + const clientDerivedNode = clientHDNode.derivePath(derivationPath); + const serverDerivedNode = serverHDNode.derivePath(derivationPath); + + const publicKey = serverDerivedNode.publicKey; + const secretKey = clientDerivedNode.privateKey; + if (!secretKey) { + throw new Error('no client private Key'); + } + const secret = Buffer.from( + // FIXME(BG-34386): we should use `secp256k1.ecdh()` in the future + // see discussion here https://github.com/bitcoin-core/secp256k1/issues/352 + secp256k1.publicKeyTweakMul(publicKey, secretKey) + ).toString('hex'); + + // decrypt token with symmetric ECDH key + let response: TokenIssuance; + try { + response = { + token: await this.decryptAsync({ + input: responseBody.encryptedToken, + password: secret, + }), + }; + } catch (e) { + e.errorCode = 'token_decryption_failure'; + console.error('Failed to decrypt token.'); + throw e; + } + if (!this._ecdhXprv) { + response.ecdhXprv = ecdhXprv; + } + return response; + } + /** */ async verifyPassword(params: VerifyPasswordOptions = {}): Promise { @@ -1393,7 +1509,7 @@ export class BitGoAPI implements BitGoBase { // verify the authenticity of the server's response before proceeding any further await verifyResponseAsync(this, this._token, 'post', request, response, this._authVersion); - const responseDetails = this.handleTokenIssuance(response.body); + const responseDetails = await this.handleTokenIssuanceAsync(response.body); response.body.token = responseDetails.token; return handleResponseResult()(response); @@ -1735,6 +1851,7 @@ export class BitGoAPI implements BitGoBase { } /** + * TODO: deprecate this function in favor of splitSecretAsync when v2 encryption is the default * Split a secret into shards using Shamir Secret Sharing. * @param seed A hexadecimal secret to split * @param passwords An array of the passwords used to encrypt each share @@ -1767,6 +1884,39 @@ export class BitGoAPI implements BitGoBase { } /** + * Async version of splitSecret with v2 encrypt/decrypt support. + * @param seed + * @param passwords + * @param m + */ + async splitSecretAsync({ seed, passwords, m }: SplitSecretOptions): Promise { + if (!Array.isArray(passwords)) { + throw new Error('passwords must be an array'); + } + if (!_.isInteger(m) || m < 2) { + throw new Error('m must be a positive integer greater than or equal to 2'); + } + + if (passwords.length < m) { + throw new Error('passwords array length cannot be less than m'); + } + + const n = passwords.length; + const secrets: string[] = shamir.share(seed, n, m); + const shards = await Promise.all( + secrets.map((shard, i) => this.encryptAsync({ input: shard, password: passwords[i] })) + ); + const node = bip32.fromSeed(Buffer.from(seed, 'hex')); + return { + xpub: node.neutered().toBase58(), + m, + n, + seedShares: shards, + }; + } + + /** + * TODO: deprecate this function in favor of reconstituteSecretAsync when v2 encryption is the default * Reconstitute a secret which was sharded with `splitSecret`. * @param shards * @param passwords @@ -1796,7 +1946,36 @@ export class BitGoAPI implements BitGoBase { } /** - * + * Async version of reconstituteSecret with v2 encrypt/decrypt support. + * @param shards + * @param passwords + */ + async reconstituteSecretAsync({ shards, passwords }: ReconstituteSecretOptions): Promise { + if (!Array.isArray(shards)) { + throw new Error('shards must be an array'); + } + if (!Array.isArray(passwords)) { + throw new Error('passwords must be an array'); + } + + if (shards.length !== passwords.length) { + throw new Error('shards and passwords arrays must have same length'); + } + + const secrets = await Promise.all( + shards.map((shard, i) => this.decryptAsync({ input: shard, password: passwords[i] })) + ); + const seed: string = shamir.combine(secrets); + const node = bip32.fromSeed(Buffer.from(seed, 'hex')); + return { + xpub: node.neutered().toBase58() as string, + xprv: node.toBase58() as string, + seed, + }; + } + + /** + * TODO: Deprecate this function in favour of verifyShardsAsync when v2 encryption is the default. * @param shards * @param passwords * @param m @@ -1872,6 +2051,73 @@ export class BitGoAPI implements BitGoBase { return true; } + /** + * Async version of verifyShards with v2 encrypt/decrypt support. + * @param shards + * @param passwords + * @param m + * @param xpub + */ + async verifyShardsAsync({ shards, passwords, m, xpub }: VerifyShardsOptions): Promise { + const generateCombinations = (array: string[], m: number, entryIndices: number[] = []): string[][] => { + let combinations: string[][] = []; + + if (entryIndices.length === m) { + const currentCombination = _.at(array, entryIndices); + return [currentCombination]; + } + + let entryIndex = _.last(entryIndices); + if (_.isUndefined(entryIndex)) { + entryIndex = -1; + } + for (let i = entryIndex + 1; i < array.length; i++) { + const currentEntryIndices = [...entryIndices, i]; + const newCombinations = generateCombinations(array, m, currentEntryIndices); + combinations = [...combinations, ...newCombinations]; + } + + return combinations; + }; + + if (!Array.isArray(shards)) { + throw new Error('shards must be an array'); + } + if (!Array.isArray(passwords)) { + throw new Error('passwords must be an array'); + } + + if (shards.length !== passwords.length) { + throw new Error('shards and passwords arrays must have same length'); + } + + const secrets = await Promise.all( + shards.map((shard, i) => this.decryptAsync({ input: shard, password: passwords[i] })) + ); + const secretCombinations = generateCombinations(secrets, m); + const seeds = secretCombinations.map((currentCombination) => { + return shamir.combine(currentCombination); + }); + const uniqueSeeds = _.uniq(seeds); + if (uniqueSeeds.length !== 1) { + return false; + } + const seed = _.first(uniqueSeeds); + const node = bip32.fromSeed(Buffer.from(seed, 'hex')); + const restoredXpub = node.neutered().toBase58(); + + if (!_.isUndefined(xpub)) { + if (!_.isString(xpub)) { + throw new Error('xpub must be a string'); + } + if (restoredXpub !== xpub) { + return false; + } + } + + return true; + } + /** * @deprecated - use `getSharedSecret()` */ @@ -1914,7 +2160,7 @@ export class BitGoAPI implements BitGoBase { const userEcdhKeychain = await this.getECDHKeychain(userSigningKey.ecdhKeychain); let xprv; try { - xprv = this.decrypt({ + xprv = await this.decryptAsync({ password: password, input: userEcdhKeychain.encryptedXprv, }); diff --git a/modules/sdk-api/src/v1/travelRule.ts b/modules/sdk-api/src/v1/travelRule.ts index 395ba5da4b..ad67e7af4d 100644 --- a/modules/sdk-api/src/v1/travelRule.ts +++ b/modules/sdk-api/src/v1/travelRule.ts @@ -110,6 +110,7 @@ TravelRule.prototype.validateTravelInfo = function (info) { * keychain: keychain object (with xprv) * Returns: * the tx object, augmented with decrypted travelInfo fields + * TODO: Deprecate in favor of decryptReceivedTravelInfoAsync once v2 encryption is default. */ TravelRule.prototype.decryptReceivedTravelInfo = function (params: DecryptReceivedTravelRuleOptions = {}) { const tx = params.tx; @@ -144,6 +145,45 @@ TravelRule.prototype.decryptReceivedTravelInfo = function (params: DecryptReceiv return tx; }; +/** + * Async version of decryptReceivedTravelInfo with v2 encrypt/decrypt support. + */ +TravelRule.prototype.decryptReceivedTravelInfoAsync = async function (params: DecryptReceivedTravelRuleOptions = {}) { + const tx = params.tx; + if (!_.isObject(tx)) { + throw new Error('expecting tx param to be object'); + } + + if (!tx.receivedTravelInfo || !tx.receivedTravelInfo.length) { + return tx; + } + + const keychain = params.keychain; + if (!_.isObject(keychain) || !_.isString(keychain.xprv)) { + throw new Error('expecting keychain param with xprv'); + } + const hdNode = bip32.fromBase58(keychain.xprv); + + for (const info of tx.receivedTravelInfo) { + const key = hdNode.derivePath(sanitizeLegacyPath(info.toPubKeyPath)); + const secret = getSharedSecret(key, Buffer.from(info.fromPubKey, 'hex')).toString('hex'); + try { + const decrypted = await this.bitgo.decryptAsync({ + input: info.encryptedTravelInfo, + password: secret, + }); + info.travelInfo = JSON.parse(decrypted); + } catch (err) { + console.error('failed to decrypt or parse travel info for ', info.transactionId + ':' + info.outputIndex); + } + } + + return tx; +}; + +/** + * TODO: Deprecate in favor of prepareParamsAsync once v2 encryption is default. + */ TravelRule.prototype.prepareParams = function (params) { params = params || {}; params.txid = params.txid || params.hash; @@ -198,6 +238,63 @@ TravelRule.prototype.prepareParams = function (params) { return result; }; +/** + * Async version of prepareParams with v2 encrypt/decrypt support. + */ +TravelRule.prototype.prepareParamsAsync = async function (params) { + params = params || {}; + params.txid = params.txid || params.hash; + common.validateParams(params, ['txid'], ['fromPrivateInfo']); + const txid = params.txid; + const recipient: Recipient | undefined = params.recipient; + let travelInfo = params.travelInfo; + if (!recipient || !_.isObject(recipient)) { + throw new Error('invalid or missing recipient'); + } + if (!travelInfo || !_.isObject(travelInfo)) { + throw new Error('invalid or missing travelInfo'); + } + if (!params.noValidate) { + travelInfo = this.validateTravelInfo(travelInfo); + } + + // Fill in toEnterprise if not already filled + if (!travelInfo.toEnterprise && recipient.enterprise) { + travelInfo.toEnterprise = recipient.enterprise; + } + + // If a key was not provided, create a new random key + let fromKey = params.fromKey && utxolib.ECPair.fromWIF(params.fromKey, getNetwork() as utxolib.BitcoinJSNetwork); + if (!fromKey) { + fromKey = makeRandomKey(); + } + + // Compute the shared key for encryption + const sharedSecret = getSharedSecret(fromKey, Buffer.from(recipient.pubKey, 'hex')).toString('hex'); + + // JSON-ify and encrypt the payload + const travelInfoJSON = JSON.stringify(travelInfo); + const encryptedTravelInfo = await this.bitgo.encryptAsync({ + input: travelInfoJSON, + password: sharedSecret, + }); + + const result = { + txid: txid, + outputIndex: recipient.outputIndex, + toPubKey: recipient.pubKey, + fromPubKey: fromKey.publicKey.toString('hex'), + encryptedTravelInfo: encryptedTravelInfo, + fromPrivateInfo: undefined, + }; + + if (params.fromPrivateInfo) { + result.fromPrivateInfo = params.fromPrivateInfo; + } + + return result; +}; + /** * Send travel data to the server for a transaction */ diff --git a/modules/sdk-api/test/unit/bitgoAPI.ts b/modules/sdk-api/test/unit/bitgoAPI.ts index 6291c0fdba..9734483caa 100644 --- a/modules/sdk-api/test/unit/bitgoAPI.ts +++ b/modules/sdk-api/test/unit/bitgoAPI.ts @@ -234,9 +234,7 @@ describe('Constructor', function () { let bitgo: BitGoAPI; beforeEach(function () { - bitgo = new BitGoAPI({ - env: 'test', - }); + bitgo = new BitGoAPI({ env: 'test' }); }); afterEach(function () { @@ -244,43 +242,27 @@ describe('Constructor', function () { }); it('should throw if no params are provided', function () { - try { - // @ts-expect-error - intentionally calling with no params for test - bitgo.decryptKeys(); - throw new Error('Expected error but got none'); - } catch (e) { - e.message.should.containEql('Missing parameter'); - } + // @ts-expect-error - intentionally calling with no params for test + (() => bitgo.decryptKeys()).should.throw('Missing parameter: walletIdEncryptedKeyPairs'); }); it('should throw if walletIdEncryptedKeyPairs is missing', function () { - try { - // @ts-expect-error - intentionally missing required param - bitgo.decryptKeys({ password: 'password123' }); - throw new Error('Expected error but got none'); - } catch (e) { - e.message.should.containEql('Missing parameter: walletIdEncryptedKeyPairs'); - } + // @ts-expect-error - intentionally missing required param + (() => bitgo.decryptKeys({ password: 'password123' })).should.throw( + 'Missing parameter: walletIdEncryptedKeyPairs' + ); }); it('should throw if password is missing', function () { - try { - // @ts-expect-error - intentionally missing required param - bitgo.decryptKeys({ walletIdEncryptedKeyPairs: [] }); - throw new Error('Expected error but got none'); - } catch (e) { - e.message.should.containEql('Missing parameter: password'); - } + // @ts-expect-error - intentionally missing required param + (() => bitgo.decryptKeys({ walletIdEncryptedKeyPairs: [] })).should.throw('Missing parameter: password'); }); it('should throw if walletIdEncryptedKeyPairs is not an array', function () { - try { - // @ts-expect-error - intentionally providing wrong type - bitgo.decryptKeys({ walletIdEncryptedKeyPairs: 'not an array', password: 'password123' }); - throw new Error('Expected error but got none'); - } catch (e) { - e.message.should.equal('walletIdEncryptedKeyPairs must be an array'); - } + // @ts-expect-error - intentionally providing wrong type + (() => bitgo.decryptKeys({ walletIdEncryptedKeyPairs: 'not an array', password: 'password123' })).should.throw( + 'walletIdEncryptedKeyPairs must be an array' + ); }); it('should return empty array for empty walletIdEncryptedKeyPairs', function () { @@ -290,76 +272,47 @@ describe('Constructor', function () { }); it('should throw if any walletId is missing or not a string', function () { - try { + (() => bitgo.decryptKeys({ walletIdEncryptedKeyPairs: [ // @ts-expect-error - intentionally missing walletId - { - encryptedPrv: 'encrypted-data', - }, + { encryptedPrv: 'encrypted-data' }, ], password: 'password123', - }); - throw new Error('Expected error but got none'); - } catch (e) { - e.message.should.equal('each key pair must have a string walletId'); - } + })).should.throw('each key pair must have a string walletId'); - try { + (() => bitgo.decryptKeys({ walletIdEncryptedKeyPairs: [ - { - // @ts-expect-error - intentionally providing wrong type - walletId: 123, - encryptedPrv: 'encrypted-data', - }, + // @ts-expect-error - intentionally providing wrong type + { walletId: 123, encryptedPrv: 'encrypted-data' }, ], password: 'password123', - }); - throw new Error('Expected error but got none'); - } catch (e) { - e.message.should.equal('each key pair must have a string walletId'); - } + })).should.throw('each key pair must have a string walletId'); }); it('should throw if any encryptedPrv is missing or not a string', function () { - try { + (() => bitgo.decryptKeys({ walletIdEncryptedKeyPairs: [ // @ts-expect-error - intentionally missing encryptedPrv - { - walletId: 'wallet-id-1', - }, + { walletId: 'wallet-id-1' }, ], password: 'password123', - }); - throw new Error('Expected error but got none'); - } catch (e) { - e.message.should.equal('each key pair must have a string encryptedPrv'); - } + })).should.throw('each key pair must have a string encryptedPrv'); - try { + (() => bitgo.decryptKeys({ walletIdEncryptedKeyPairs: [ - { - walletId: 'wallet-id-1', - // @ts-expect-error - intentionally providing wrong type - encryptedPrv: 123, - }, + // @ts-expect-error - intentionally providing wrong type + { walletId: 'wallet-id-1', encryptedPrv: 123 }, ], password: 'password123', - }); - throw new Error('Expected error but got none'); - } catch (e) { - e.message.should.equal('each key pair must have a string encryptedPrv'); - } + })).should.throw('each key pair must have a string encryptedPrv'); }); it('should return walletIds of keys that failed to decrypt', function () { - // Create a stub for the decrypt method const decryptStub = sinon.stub(bitgo, 'decrypt'); - - // Make it succeed for first wallet and fail for second wallet decryptStub.onFirstCall().returns('decrypted-key-1'); decryptStub.onSecondCall().throws(new Error('decryption failed')); @@ -377,17 +330,14 @@ describe('Constructor', function () { }); it('should correctly process multiple wallet keys', function () { - // Create a spy on the decrypt method const decryptStub = sinon.stub(bitgo, 'decrypt'); - - // Configure the stub to throw for specific wallets decryptStub .withArgs({ input: 'encrypted-data-2', password: 'password123' }) .throws(new Error('decryption failed')); decryptStub .withArgs({ input: 'encrypted-data-4', password: 'password123' }) .throws(new Error('decryption failed')); - decryptStub.returns('success'); // Default return for other calls + decryptStub.returns('success'); const result = bitgo.decryptKeys({ walletIdEncryptedKeyPairs: [ @@ -399,10 +349,63 @@ describe('Constructor', function () { password: 'password123', }); - // Should be called once for each wallet decryptStub.callCount.should.equal(4); + result.should.be.an.Array(); + result.should.have.length(2); + result.should.containDeep(['wallet-id-2', 'wallet-id-4']); + }); + }); + + describe('decryptKeysAsync', function () { + let bitgo: BitGoAPI; + + beforeEach(function () { + bitgo = new BitGoAPI({ env: 'test' }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should return walletIds of keys that failed to decrypt', async function () { + const decryptAsyncStub = sinon.stub(bitgo, 'decryptAsync'); + decryptAsyncStub.onFirstCall().resolves('decrypted-key-1'); + decryptAsyncStub.onSecondCall().rejects(new Error('decryption failed')); + + const result = await bitgo.decryptKeysAsync({ + walletIdEncryptedKeyPairs: [ + { walletId: 'wallet-id-1', encryptedPrv: 'encrypted-data-1' }, + { walletId: 'wallet-id-2', encryptedPrv: 'encrypted-data-2' }, + ], + password: 'password123', + }); + + result.should.be.an.Array(); + result.should.have.length(1); + result[0].should.equal('wallet-id-2'); + }); + + it('should correctly process multiple wallet keys', async function () { + const decryptAsyncStub = sinon.stub(bitgo, 'decryptAsync'); + decryptAsyncStub + .withArgs({ input: 'encrypted-data-2', password: 'password123' }) + .rejects(new Error('decryption failed')); + decryptAsyncStub + .withArgs({ input: 'encrypted-data-4', password: 'password123' }) + .rejects(new Error('decryption failed')); + decryptAsyncStub.resolves('success'); + + const result = await bitgo.decryptKeysAsync({ + walletIdEncryptedKeyPairs: [ + { walletId: 'wallet-id-1', encryptedPrv: 'encrypted-data-1' }, + { walletId: 'wallet-id-2', encryptedPrv: 'encrypted-data-2' }, + { walletId: 'wallet-id-3', encryptedPrv: 'encrypted-data-3' }, + { walletId: 'wallet-id-4', encryptedPrv: 'encrypted-data-4' }, + ], + password: 'password123', + }); - // Should include only the failed wallet IDs + decryptAsyncStub.callCount.should.equal(4); result.should.be.an.Array(); result.should.have.length(2); result.should.containDeep(['wallet-id-2', 'wallet-id-4']); diff --git a/modules/sdk-api/test/unit/v1/travelRule.ts b/modules/sdk-api/test/unit/v1/travelRule.ts new file mode 100644 index 0000000000..4a307eeeae --- /dev/null +++ b/modules/sdk-api/test/unit/v1/travelRule.ts @@ -0,0 +1,170 @@ +import * as sinon from 'sinon'; +import * as should from 'should'; +import { BitGoAPI } from '../../../src/bitgoAPI'; + +const TravelRule = require('../../../src/v1/travelRule'); + +// Use a real 33-byte compressed public key from the utxo-lib test vector set. +const KNOWN_RECIPIENT_PUB = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + +describe('TravelRule unit tests', () => { + let bitgo: BitGoAPI; + let travel: typeof TravelRule; + + beforeEach(() => { + bitgo = new BitGoAPI({ env: 'test' }); + travel = new TravelRule(bitgo); + }); + + afterEach(() => { + sinon.restore(); + }); + + // --------------------------------------------------------------------------- + // decryptReceivedTravelInfo (sync) + // --------------------------------------------------------------------------- + describe('decryptReceivedTravelInfo', () => { + it('throws when tx param is missing', () => { + should.throws(() => travel.decryptReceivedTravelInfo({}), /expecting tx param to be object/); + }); + + it('returns tx unchanged when receivedTravelInfo is empty', () => { + const tx = { receivedTravelInfo: [] }; + const result = travel.decryptReceivedTravelInfo({ tx }); + result.should.equal(tx); + }); + + it('returns tx unchanged when receivedTravelInfo is not present', () => { + const tx = { id: 'txid123' }; + const result = travel.decryptReceivedTravelInfo({ tx }); + result.should.equal(tx); + }); + }); + + // --------------------------------------------------------------------------- + // decryptReceivedTravelInfoAsync + // --------------------------------------------------------------------------- + describe('decryptReceivedTravelInfoAsync', () => { + it('throws when tx param is missing', async () => { + await travel.decryptReceivedTravelInfoAsync({}).should.be.rejectedWith(/expecting tx param to be object/); + }); + + it('returns tx unchanged when receivedTravelInfo is empty', async () => { + const tx = { receivedTravelInfo: [] }; + const result = await travel.decryptReceivedTravelInfoAsync({ tx }); + result.should.equal(tx); + }); + + it('returns tx unchanged when receivedTravelInfo is not present', async () => { + const tx = { id: 'txid456' }; + const result = await travel.decryptReceivedTravelInfoAsync({ tx }); + result.should.equal(tx); + }); + + it('calls decryptAsync for each travel info entry', async () => { + const decryptStub = sinon.stub(bitgo, 'decryptAsync').resolves(JSON.stringify({ fromUserName: 'Alice' })); + + const xprv = + 'xprv9s21ZrQH143K2fJ91S4BRsupcYrE6mmY96fcX5HkhoTrrwmwjd16Cn87cWinJjByrfpojjx7ezsJLx7TAKLT8m8hM5Kax9YcoxnBeJZ3t2k'; + const keychain = { xprv }; + const tx = { + receivedTravelInfo: [ + { + toPubKeyPath: 'm/0/0', + fromPubKey: KNOWN_RECIPIENT_PUB, + encryptedTravelInfo: 'someEncryptedBlob', + travelInfo: '', + transactionId: 'txid1', + outputIndex: 0, + }, + ], + }; + + const result = await travel.decryptReceivedTravelInfoAsync({ tx, keychain }); + decryptStub.callCount.should.equal(1); + result.should.equal(tx); + }); + }); + + // --------------------------------------------------------------------------- + // prepareParams (sync) + // --------------------------------------------------------------------------- + describe('prepareParams', () => { + it('throws when recipient is missing', () => { + should.throws( + () => + travel.prepareParams({ + txid: 'abc123', + travelInfo: { fromUserName: 'Alice' }, + }), + /invalid or missing recipient/ + ); + }); + + it('throws when travelInfo is missing', () => { + should.throws( + () => + travel.prepareParams({ + txid: 'abc123', + recipient: { enterprise: 'SDKTest', pubKey: KNOWN_RECIPIENT_PUB, outputIndex: '0' }, + }), + /invalid or missing travelInfo/ + ); + }); + + it('calls bitgo.encrypt and returns expected shape', () => { + const encryptStub = sinon.stub(bitgo, 'encrypt').returns('encryptedBlob'); + + const result = travel.prepareParams({ + txid: 'abc123', + recipient: { enterprise: 'SDKTest', pubKey: KNOWN_RECIPIENT_PUB, outputIndex: '0' }, + travelInfo: { fromUserName: 'Alice', toAddress: '1BitGo' }, + }); + + encryptStub.callCount.should.equal(1); + result.should.have.property('txid', 'abc123'); + result.should.have.property('toPubKey', KNOWN_RECIPIENT_PUB); + result.should.have.property('fromPubKey').which.is.a.String(); + result.should.have.property('encryptedTravelInfo', 'encryptedBlob'); + }); + }); + + // --------------------------------------------------------------------------- + // prepareParamsAsync + // --------------------------------------------------------------------------- + describe('prepareParamsAsync', () => { + it('throws when recipient is missing', async () => { + await travel + .prepareParamsAsync({ + txid: 'abc123', + travelInfo: { fromUserName: 'Alice' }, + }) + .should.be.rejectedWith(/invalid or missing recipient/); + }); + + it('throws when travelInfo is missing', async () => { + await travel + .prepareParamsAsync({ + txid: 'abc123', + recipient: { enterprise: 'SDKTest', pubKey: KNOWN_RECIPIENT_PUB, outputIndex: '0' }, + }) + .should.be.rejectedWith(/invalid or missing travelInfo/); + }); + + it('calls encryptAsync and returns expected output shape', async () => { + const encryptStub = sinon.stub(bitgo, 'encryptAsync').resolves('asyncEncryptedBlob'); + + const result = await travel.prepareParamsAsync({ + txid: 'txid789', + recipient: { enterprise: 'SDKTest', pubKey: KNOWN_RECIPIENT_PUB, outputIndex: '1' }, + travelInfo: { fromUserName: 'Bob', toAddress: '1BitGo' }, + }); + + encryptStub.callCount.should.equal(1); + result.should.have.property('txid', 'txid789'); + result.should.have.property('toPubKey', KNOWN_RECIPIENT_PUB); + result.should.have.property('fromPubKey').which.is.a.String(); + result.should.have.property('encryptedTravelInfo', 'asyncEncryptedBlob'); + }); + }); +}); diff --git a/modules/sdk-core/src/bitgo/bitgoBase.ts b/modules/sdk-core/src/bitgo/bitgoBase.ts index a8ca3156c8..ed582f2f37 100644 --- a/modules/sdk-core/src/bitgo/bitgoBase.ts +++ b/modules/sdk-core/src/bitgo/bitgoBase.ts @@ -18,6 +18,7 @@ export interface BitGoBase { decrypt(params: DecryptOptions): string; decryptAsync(params: DecryptOptions): Promise; decryptKeys(params: DecryptKeysOptions): string[]; + decryptKeysAsync(params: DecryptKeysOptions): Promise; del(url: string): BitGoRequest; encrypt(params: EncryptOptions): string; encryptAsync(params: EncryptOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/internal/keycard.ts b/modules/sdk-core/src/bitgo/internal/keycard.ts index 587357c79c..1873e3b8cb 100644 --- a/modules/sdk-core/src/bitgo/internal/keycard.ts +++ b/modules/sdk-core/src/bitgo/internal/keycard.ts @@ -92,7 +92,12 @@ interface GetKeyDataOptions { backupKeyID?: string; } +type GetKeyDataAsyncOptions = Omit & { + encrypt: (params: { input: string; password: string }) => Promise; +}; + /** + * TODO: Deprecate this function in favor of getKeyDataAsync once v2 encryption is default. * Collect all data which will go onto the keycard * @param options */ @@ -196,6 +201,110 @@ function getKeyData(options: GetKeyDataOptions): any { return qrData; } +/** + * Async version of getKeyData with support for v2 (Argon2id) encryption. + * @param options + */ +async function getKeyDataAsync(options: GetKeyDataAsyncOptions): Promise { + const { + encrypt, + userKeychain, + bitgoKeychain, + backupKeychain, + coinShortName, + passphrase, + passcodeEncryptionCode, + walletKeyID, + backupKeyID, + } = options; + + // When using just 'generateWallet', we get back an unencrypted prv for the backup keychain + // If the user passes in their passphrase, we can encrypt it + if (backupKeychain.prv && passphrase) { + backupKeychain.encryptedPrv = await encrypt({ + input: backupKeychain.prv, + password: passphrase, + }); + } + + // If we have the passcode encryption code, create a box D with the encryptedWalletPasscode + let encryptedWalletPasscode; + if (passphrase && passcodeEncryptionCode) { + encryptedWalletPasscode = await encrypt({ + input: passphrase, + password: passcodeEncryptionCode, + }); + } + + // PDF QR Code data + const qrData: any = { + user: { + title: 'A: User Key', + desc: 'This is your private key, encrypted with your passcode.', + data: userKeychain.encryptedPrv, + }, + backup: { + title: 'B: Backup Key', + desc: 'This is your backup private key, encrypted with your passcode.', + data: backupKeychain.encryptedPrv, + }, + bitgo: { + title: 'C: BitGo Public Key', + desc: + 'This is the public part of the key that BitGo will use to ' + + 'co-sign transactions\r\nwith you on your wallet.', + data: bitgoKeychain.pub, + }, + passcode: { + title: 'D: Encrypted Wallet Password', + desc: 'This is the wallet password, encrypted client-side ' + 'with a key held by\r\nBitGo.', + data: encryptedWalletPasscode, + }, + }; + + if (walletKeyID) { + qrData.user.keyID = walletKeyID; + } + + if (backupKeyID) { + qrData.backup.keyID = backupKeyID; + } + + if (isUndefined(userKeychain.encryptedPrv)) { + // User provided their own key - this is a cold wallet + qrData.user.title = 'A: Provided User Key'; + qrData.user.desc = 'This is the public key you provided for your wallet.'; + qrData.user.data = userKeychain.pub; + + // The user provided their own public key, we can remove box D + delete qrData.passcode; + } else if (isUndefined(encryptedWalletPasscode)) { + delete qrData.passcode; + } + + if (!isUndefined(backupKeychain.provider)) { + const backupKeyProviderName = backupKeychain.provider; + // Backup key held with KRS + qrData.backup = { + title: 'B: Backup Key', + desc: + `This is the public key held at ${backupKeyProviderName}` + + `, an ${coinShortName} recovery service. If you lose\r\nyour key, ${backupKeyProviderName}` + + ' will be able to sign transactions to recover funds.', + data: backupKeychain.pub, + }; + } else if (isUndefined(backupKeychain.encryptedPrv)) { + // User supplied the xpub + qrData.backup = { + title: 'B: Backup Key', + desc: 'This is the public portion of your backup key, which you provided.', + data: backupKeychain.pub, + }; + } + + return qrData; +} + interface DrawKeycardOptions extends GetKeyDataOptions { jsPDF: any; QRCode: any; @@ -205,7 +314,12 @@ interface DrawKeycardOptions extends GetKeyDataOptions { coinName: string; } +export type DrawKeycardAsyncOptions = Omit & { + encrypt: (params: { input: string; password: string }) => Promise; +}; + /** + * TODO: Deprecate this function in favor of drawKeycardAsync once v2 encryption is default. * Draw a keycard into a new pdf document object * @param options */ @@ -382,3 +496,184 @@ export function drawKeycard(options: DrawKeycardOptions): any { return doc; } + +/** + * Async version of drawKeycard with support for v2 (Argon2id) encryption. + * Use this when the encrypt callback may return a Promise (e.g. encryptAsync). + * + * Draw a keycard into a new pdf document object + * @param options + */ +export async function drawKeycardAsync(options: DrawKeycardAsyncOptions): Promise { + const { jsPDF, QRCode, coinShortName, activationCode, walletLabel, coinName } = options; + + const margin = 30; + + const font = { + header: 24, + subheader: 15, + body: 12, + }; + + const color = { + black: '#000000', + darkgray: '#4c4c4c', + gray: '#9b9b9b', + red: '#e21e1e', + }; + + // document details + const width = 8.5 * 72; + let y = 0; + + // Helpers for data formatting / positioning on the paper + const left = (x) => margin + x; + const moveDown = (yDelta) => { + y += yDelta; + }; + + const doc = new jsPDF('portrait', 'pt', 'letter'); + doc.setFont('helvetica'); + + // PDF Header Area - includes the logo and company name + // This is data for the BitGo logo in the top left of the PDF + moveDown(30); + + // We don't currently add an image, since that path is dependent on BitGo frontend + // doc.addImage(coinUtility.getSelectedCoinObj().keyCardImage, left(0), y + 10); + + // Activation Code + moveDown(8); + doc.setFontSize(font.body).setTextColor(color.gray); + doc.text('Activation Code', left(460), y); + + doc.setFontSize(font.header).setTextColor(color.black); + moveDown(25); + doc.text('Your BitGo KeyCard', left(150), y); + doc.setFontSize(font.header).setTextColor(color.gray); + doc.text(activationCode.toString(), left(460), y); + + // Subheader + // titles + moveDown(margin); + doc.setFontSize(font.body).setTextColor(color.gray); + doc.text( + `Created on ${new Date().toDateString()} by ${window.location.hostname} for wallet named ${walletLabel}`, + left(0), + y + ); + // copy + moveDown(25); + doc.setFontSize(font.subheader).setTextColor(color.black); + doc.text(walletLabel, left(0), y); + // Red Bar + moveDown(20); + doc.setFillColor(255, 230, 230); + doc.rect(left(0), y, width - 2 * margin, 32, 'F'); + + // warning message + moveDown(20); + doc.setFontSize(font.body).setTextColor(color.red); + doc.text('Print this document, or keep it securely offline. See second page for FAQ.', left(75), y); + + const { + encrypt, + passphrase, + passcodeEncryptionCode, + walletKeyID, + backupKeyID, + userKeychain, + bitgoKeychain, + backupKeychain, + } = options; + + // Get the data for the first page (qr codes) + const keyData = await getKeyDataAsync({ + encrypt, + coinShortName, + passphrase, + passcodeEncryptionCode, + walletKeyID, + backupKeyID, + userKeychain, + bitgoKeychain, + backupKeychain, + }); + + // Generate the first page's data for the backup PDF + moveDown(35); + const qrSize = 130; + + // Draw each Box with QR code and description + Object.keys(keyData).forEach(function (keyType) { + const key = keyData[keyType]; + const topY = y; + + // Don't indent if we're not producing QR codes + const textLeft = !!QRCode ? left(qrSize + 15) : left(15); + + // Draw a QR code if library is available + if (QRCode) { + const dataURL = new QRCode({ value: key.data, size: qrSize }).toDataURL('image/jpeg'); + doc.addImage(dataURL, left(0), y, qrSize, qrSize); + } + + doc.setFontSize(font.subheader).setTextColor(color.black); + moveDown(10); + doc.text(key.title, textLeft, y); + moveDown(15); + doc.setFontSize(font.body).setTextColor(color.darkgray); + doc.text(key.desc, textLeft, y); + moveDown(30); + doc.setFontSize(font.body - 2); + doc.text('Data:', textLeft, y); + moveDown(15); + const innerWidth = 72 * 8.5 - textLeft - 30; + doc.setFont('courier').setFontSize(9).setTextColor(color.black); + const lines = doc.splitTextToSize(key.data, innerWidth); + doc.text(lines, textLeft, y); + + // Add key ID (derivation string) if it exists + if (key.keyID) { + const text = 'Key Id: ' + key.keyID; + // Gray bar + moveDown(45); + doc.setFillColor(247, 249, 249); // Gray background + doc.setDrawColor(0, 0, 0); // Border + doc.rect(textLeft, y, width, 15, 'FD'); + + doc.text(text, textLeft + 5, y + 10); + } + + doc.setFont('helvetica'); + // Move down the size of the QR code minus accumulated height on the right side, plus buffer + moveDown(qrSize - (y - topY) + 15); + }); + + // Add a new page (Q + A page) + doc.addPage(); + + // 2nd page title + y = 0; + moveDown(55); + doc.setFontSize(font.header).setTextColor(color.black); + doc.text('BitGo KeyCard FAQ', left(0), y); + + const questions = generateQuestions(coinName); + + // Draw the Q + A data on the second page + moveDown(30); + questions.forEach(function (q) { + doc.setFontSize(font.subheader).setTextColor(color.black); + doc.text(q.q, left(0), y); + moveDown(20); + doc.setFontSize(font.body).setTextColor(color.darkgray); + q.a.forEach(function (line) { + doc.text(line, left(0), y); + moveDown(font.body + 3); + }); + moveDown(22); + }); + + return doc; +} diff --git a/modules/sdk-core/src/bitgo/recovery/initiate.ts b/modules/sdk-core/src/bitgo/recovery/initiate.ts index e66e39ad49..c4619ef03a 100644 --- a/modules/sdk-core/src/bitgo/recovery/initiate.ts +++ b/modules/sdk-core/src/bitgo/recovery/initiate.ts @@ -101,6 +101,9 @@ export function getIsUnsignedSweep({ return backupKey.startsWith('xpub') && userKey.startsWith('xpub'); } +/** + * TODO: Deprecate this function in favour of validateKeyAsync when v2 encryption is the default. + */ export function validateKey( bitgo: BitGoBase, { key, source, passphrase, isUnsignedSweep, isKrsRecovery }: ValidateKeyOptions @@ -122,6 +125,32 @@ export function validateKey( } } +/** + * Async version of validateKey with v2 encrypt/decrypt support. + */ +export async function validateKeyAsync( + bitgo: BitGoBase, + { key, source, passphrase, isUnsignedSweep, isKrsRecovery }: ValidateKeyOptions +): Promise { + if (!key.startsWith('xprv') && !isUnsignedSweep) { + try { + if (source === 'user' || (source === 'backup' && !isKrsRecovery)) { + return bip32.fromBase58(await bitgo.decryptAsync({ password: passphrase, input: key })); + } + } catch (e) { + throw new Error(`Failed to decrypt ${source} key with passcode - try again!`); + } + } + try { + return bip32.fromBase58(key); + } catch (e) { + throw new Error(`Failed to validate ${source} key - try again!`); + } +} + +/** + * TODO: Deprecate this function in favour of getBip32KeysAsync when v2 encryption is the default. + */ export function getBip32Keys( bitgo: BitGoBase, params: InitiateRecoveryOptions | InitiateConsolidationRecoveryOptions, @@ -162,3 +191,47 @@ export function getBip32Keys( return keys; } + +/** + * Async version of getBip32Keys with v2 encrypt/decrypt support. + */ +export async function getBip32KeysAsync( + bitgo: BitGoBase, + params: InitiateRecoveryOptions | InitiateConsolidationRecoveryOptions, + { requireBitGoXpub }: { requireBitGoXpub: boolean } +): Promise { + const isKrsRecovery = getIsKrsRecovery(params); + const isUnsignedSweep = getIsUnsignedSweep(params); + const keys = [ + // Box A + await validateKeyAsync(bitgo, { + key: params.userKey, + source: 'user', + passphrase: params.walletPassphrase, + isKrsRecovery, + isUnsignedSweep, + }), + // Box B + await validateKeyAsync(bitgo, { + key: params.backupKey, + source: 'backup', + passphrase: params.walletPassphrase, + isKrsRecovery, + isUnsignedSweep, + }), + ]; + + if (requireBitGoXpub) { + if (!params.bitgoKey) { + throw new Error(`BitGo xpub required but not provided`); + } + try { + // Box C + keys.push(bip32.fromBase58(params.bitgoKey)); + } catch (e) { + throw new Error('Failed to parse bitgo xpub!'); + } + } + + return keys; +} diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index f98e985d7d..188088b114 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -1144,6 +1144,7 @@ export interface IWallet { toGoStakingWallet(): IGoStakingWallet; toAddressBook(): IAddressBook; downloadKeycard(params?: DownloadKeycardOptions): void; + downloadKeycardAsync(params?: DownloadKeycardOptions): Promise; buildAccountConsolidations(params?: BuildConsolidationTransactionOptions): Promise; sendAccountConsolidation(params?: PrebuildAndSignTransactionOptions): Promise; sendAccountConsolidations(params?: BuildConsolidationTransactionOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index c5ccbd4336..05c24ae06a 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -30,7 +30,7 @@ import { NeedUserSignupError, } from '../errors'; import { SubmitTransactionResponse } from '../inscriptionBuilder'; -import { drawKeycard } from '../internal'; +import { drawKeycard, drawKeycardAsync } from '../internal'; import * as internal from '../internal/internal'; import { decryptKeychainPrivateKey, @@ -1781,7 +1781,7 @@ export class Wallet implements IWallet { const needsKeychain = shareOption.permissions && shareOption.permissions.includes('spend'); if (needsKeychain && decryptedKeychain) { - const sharedKeychain = this.encryptPrvForUser( + const sharedKeychain = await this.encryptPrvForUserAsync( decryptedKeychain.prv, decryptedKeychain.pub, shareOption.pubKey, @@ -1886,6 +1886,7 @@ export class Wallet implements IWallet { } /** + * TODO: Deprecate this function in favour of encryptPrvForUserAsync when v2 encryption is the default. * Encrypts a decrypted private key for sharing with a specific user. * This is the pure encryption step - no API calls, no decryption. * @@ -1917,6 +1918,36 @@ export class Wallet implements IWallet { return keychain; } + /** + * Async version of encryptPrvForUser with v2 encrypt/decrypt support. + */ + async encryptPrvForUserAsync( + decryptedPrv: string, + pub: string, + userPubkey: string, + path: string + ): Promise { + const eckey = makeRandomKey(); + const secret = getSharedSecret(eckey, Buffer.from(userPubkey, 'hex')).toString('hex'); + const newEncryptedPrv = await this.bitgo.encryptAsync({ password: secret, input: decryptedPrv }); + + const keychain: BulkWalletShareKeychain = { + pub, + encryptedPrv: newEncryptedPrv, + fromPubKey: eckey.publicKey.toString('hex'), + toPubKey: userPubkey, + path: path, + }; + + assert(keychain.pub, 'pub must be defined for sharing'); + assert(keychain.encryptedPrv, 'encryptedPrv must be defined for sharing'); + assert(keychain.fromPubKey, 'fromPubKey must be defined for sharing'); + assert(keychain.toPubKey, 'toPubKey must be defined for sharing'); + assert(keychain.path, 'path must be defined for sharing'); + + return keychain; + } + /** * Prepares a keychain for sharing with another user. * Fetches the wallet keychain, decrypts it, and encrypts it for the recipient. @@ -1936,7 +1967,7 @@ export class Wallet implements IWallet { if (!decryptedKeychain) { return {}; } - return this.encryptPrvForUser(decryptedKeychain.prv, decryptedKeychain.pub, pubkey, path); + return await this.encryptPrvForUserAsync(decryptedKeychain.prv, decryptedKeychain.pub, pubkey, path); } catch (e) { if (e instanceof MissingEncryptedKeychainError) { // ignore this error because this looks like a cold wallet @@ -3222,6 +3253,7 @@ export class Wallet implements IWallet { } /** + * TODO: Deprecate this function in favour of downloadKeycardAsync when v2 encryption is the default. * Creates and downloads PDF keycard for wallet (requires response from wallets.generateWallet) * * Note: this is example code and is not the version used on bitgo.com @@ -3317,6 +3349,83 @@ export class Wallet implements IWallet { doc.save(`BitGo Keycard for ${walletLabel}.pdf`); } + /** + * Async version of downloadKeycard with v2 encrypt/decrypt support. + */ + async downloadKeycardAsync(params: DownloadKeycardOptions = {}): Promise { + if (!window || !window.location) { + throw new Error('The downloadKeycard function is only callable within a browser.'); + } + + const { + jsPDF, + QRCode, + userKeychain, + backupKeychain, + bitgoKeychain, + passphrase, + passcodeEncryptionCode, + walletKeyID, + backupKeyID, + activationCode = Math.floor(Math.random() * 900000 + 100000).toString(), + } = params; + + if (!jsPDF || typeof jsPDF !== 'function') { + throw new Error('Please pass in a valid jsPDF instance'); + } + + if (!userKeychain || typeof userKeychain !== 'object') { + throw new Error(`Wallet keychain must have a 'user' property`); + } + + if (!backupKeychain || typeof backupKeychain !== 'object') { + throw new Error('Backup keychain is required and must be an object'); + } + + if (!bitgoKeychain || typeof bitgoKeychain !== 'object') { + throw new Error('Bitgo keychain is required and must be an object'); + } + + if (walletKeyID && typeof walletKeyID !== 'string') { + throw new Error('walletKeyID must be a string'); + } + + if (backupKeyID && typeof backupKeyID !== 'string') { + throw new Error('backupKeyID must be a string'); + } + + if (typeof activationCode !== 'string') { + throw new Error('Activation Code must be a string'); + } + + if (activationCode.length !== 6) { + throw new Error('Activation code must be six characters'); + } + + const coinShortName = this.baseCoin.type; + const coinName = this.baseCoin.getFullName(); + const walletLabel = this._wallet.label; + + const doc = await drawKeycardAsync({ + jsPDF, + QRCode, + encrypt: (p: { input: string; password: string }) => this.bitgo.encryptAsync(p), + coinShortName, + coinName, + activationCode, + walletLabel, + passphrase, + passcodeEncryptionCode, + userKeychain, + backupKeychain, + bitgoKeychain, + walletKeyID, + backupKeyID, + }); + + doc.save(`BitGo Keycard for ${walletLabel}.pdf`); + } + /** * Builds a set of consolidation transactions for a wallet. * @param params diff --git a/modules/sdk-core/test/unit/bitgo/recovery/initiate.ts b/modules/sdk-core/test/unit/bitgo/recovery/initiate.ts new file mode 100644 index 0000000000..d21a1384cd --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/recovery/initiate.ts @@ -0,0 +1,245 @@ +import 'should'; +import * as sinon from 'sinon'; +import * as sjcl from '@bitgo/sjcl'; +import { + validateKey, + validateKeyAsync, + getBip32Keys, + getBip32KeysAsync, +} from '../../../../src/bitgo/recovery/initiate'; +import { BitGoBase } from '../../../../src/bitgo/bitgoBase'; + +// A deterministic xprv used across all tests. +const TEST_XPRV = + 'xprv9s21ZrQH143K2fJ91S4BRsupcYrE6mmY96fcX5HkhoTrrwmwjd16Cn87cWinJjByrfpojjx7ezsJLx7TAKLT8m8hM5Kax9YcoxnBeJZ3t2k'; +const TEST_XPUB = + 'xpub661MyMwAqRbcF9Nc7TbBo1rZAagiWEVPWKbDKThNG8zqjk76HAKLkaSbTn6dK2dQPfuD7xjicxCZVWvj67fP5nQ9W7QURmoMVAX8m6jZsGp'; + +/** + * Encrypt plaintext with SJCL (same algorithm as BitGoAPI.encrypt). + */ +function sjclEncrypt(password: string, input: string): string { + return sjcl.encrypt(password, input) as unknown as string; +} + +function makeMockBitGo(decryptImpl: (params: { password?: string; input: string }) => string): BitGoBase { + return { + decrypt: sinon.stub().callsFake(decryptImpl), + decryptAsync: sinon.stub().callsFake(async (params: { password?: string; input: string }) => decryptImpl(params)), + encrypt: sinon.stub(), + encryptAsync: sinon.stub(), + } as unknown as BitGoBase; +} + +describe('validateKey', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns a BIP32 node directly when key starts with xprv (bypasses decrypt)', () => { + const bitgo = makeMockBitGo(() => { + throw new Error('should not be called'); + }); + const node = validateKey(bitgo, { + key: TEST_XPRV, + source: 'user', + passphrase: 'secret', + isUnsignedSweep: false, + isKrsRecovery: false, + }); + node.toBase58().should.equal(TEST_XPRV); + (bitgo.decrypt as sinon.SinonStub).callCount.should.equal(0); + }); + + it('calls decrypt and returns BIP32 node when key is encrypted (not xprv)', () => { + const passphrase = 'hunter2'; + const encryptedKey = sjclEncrypt(passphrase, TEST_XPRV); + const bitgo = makeMockBitGo(({ password, input }) => { + if (input === encryptedKey && password === passphrase) return TEST_XPRV; + throw new Error('unexpected decrypt call'); + }); + const node = validateKey(bitgo, { + key: encryptedKey, + source: 'user', + passphrase, + isUnsignedSweep: false, + isKrsRecovery: false, + }); + node.toBase58().should.equal(TEST_XPRV); + (bitgo.decrypt as sinon.SinonStub).callCount.should.equal(1); + }); + + it('throws with friendly message when decrypt fails (wrong passphrase)', () => { + const bitgo = makeMockBitGo(() => { + throw new Error('sjcl: ccm: tag does not match'); + }); + (() => + validateKey(bitgo, { + key: 'notAnXprv', + source: 'user', + passphrase: 'wrong', + isUnsignedSweep: false, + isKrsRecovery: false, + })).should.throw('Failed to decrypt user key with passcode - try again!'); + }); +}); + +describe('validateKeyAsync', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns a BIP32 node directly when key starts with xprv (bypasses decryptAsync)', async () => { + const bitgo = makeMockBitGo(() => { + throw new Error('should not be called'); + }); + const node = await validateKeyAsync(bitgo, { + key: TEST_XPRV, + source: 'user', + passphrase: 'secret', + isUnsignedSweep: false, + isKrsRecovery: false, + }); + node.toBase58().should.equal(TEST_XPRV); + (bitgo.decryptAsync as sinon.SinonStub).callCount.should.equal(0); + }); + + it('calls decryptAsync and returns BIP32 node when key is encrypted (not xprv)', async () => { + const passphrase = 'hunter2'; + const encryptedKey = sjclEncrypt(passphrase, TEST_XPRV); + const bitgo = makeMockBitGo(({ password, input }) => { + if (input === encryptedKey && password === passphrase) return TEST_XPRV; + throw new Error('unexpected decrypt call'); + }); + const node = await validateKeyAsync(bitgo, { + key: encryptedKey, + source: 'user', + passphrase, + isUnsignedSweep: false, + isKrsRecovery: false, + }); + node.toBase58().should.equal(TEST_XPRV); + (bitgo.decryptAsync as sinon.SinonStub).callCount.should.equal(1); + }); + + it('rejects with friendly message when decryptAsync fails (wrong passphrase)', async () => { + const bitgo = makeMockBitGo(() => { + throw new Error('sjcl: ccm: tag does not match'); + }); + await validateKeyAsync(bitgo, { + key: 'notAnXprv', + source: 'user', + passphrase: 'wrong', + isUnsignedSweep: false, + isKrsRecovery: false, + }).should.be.rejectedWith('Failed to decrypt user key with passcode - try again!'); + }); + + it('skips decryptAsync for unsigned sweep (isUnsignedSweep = true)', async () => { + const bitgo = makeMockBitGo(() => { + throw new Error('should not be called'); + }); + const node = await validateKeyAsync(bitgo, { + key: TEST_XPUB, + source: 'user', + passphrase: 'secret', + isUnsignedSweep: true, + isKrsRecovery: false, + }); + node.neutered().toBase58().should.equal(TEST_XPUB); + (bitgo.decryptAsync as sinon.SinonStub).callCount.should.equal(0); + }); +}); + +describe('getBip32Keys', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns [userKey, backupKey] when both are provided as xprv', () => { + const bitgo = makeMockBitGo(() => { + throw new Error('should not be called'); + }); + const keys = getBip32Keys( + bitgo, + { userKey: TEST_XPRV, backupKey: TEST_XPRV, recoveryDestination: 'addr' }, + { requireBitGoXpub: false } + ); + keys.should.have.length(2); + keys[0].toBase58().should.equal(TEST_XPRV); + keys[1].toBase58().should.equal(TEST_XPRV); + }); + + it('throws when requireBitGoXpub is true but bitgoKey is missing', () => { + const bitgo = makeMockBitGo(() => { + throw new Error('should not be called'); + }); + (() => + getBip32Keys( + bitgo, + { userKey: TEST_XPRV, backupKey: TEST_XPRV, recoveryDestination: 'addr' }, + { requireBitGoXpub: true } + )).should.throw('BitGo xpub required but not provided'); + }); +}); + +describe('getBip32KeysAsync', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns [userKey, backupKey] when both are provided as xprv', async () => { + const bitgo = makeMockBitGo(() => { + throw new Error('should not be called'); + }); + const keys = await getBip32KeysAsync( + bitgo, + { userKey: TEST_XPRV, backupKey: TEST_XPRV, recoveryDestination: 'addr' }, + { requireBitGoXpub: false } + ); + keys.should.have.length(2); + keys[0].toBase58().should.equal(TEST_XPRV); + keys[1].toBase58().should.equal(TEST_XPRV); + }); + + it('returns three keys when requireBitGoXpub is true and bitgoKey is valid', async () => { + const bitgo = makeMockBitGo(() => { + throw new Error('should not be called'); + }); + const keys = await getBip32KeysAsync( + bitgo, + { userKey: TEST_XPRV, backupKey: TEST_XPRV, bitgoKey: TEST_XPUB, recoveryDestination: 'addr' }, + { requireBitGoXpub: true } + ); + keys.should.have.length(3); + keys[2].neutered().toBase58().should.equal(TEST_XPUB); + }); + + it('calls decryptAsync for encrypted user key', async () => { + const passphrase = 'pass1'; + const encryptedUserKey = sjclEncrypt(passphrase, TEST_XPRV); + const bitgo = makeMockBitGo(({ password, input }) => { + if (input === encryptedUserKey && password === passphrase) return TEST_XPRV; + throw new Error('unexpected decrypt call'); + }); + const keys = await getBip32KeysAsync( + bitgo, + { userKey: encryptedUserKey, backupKey: TEST_XPRV, walletPassphrase: passphrase, recoveryDestination: 'addr' }, + { requireBitGoXpub: false } + ); + keys.should.have.length(2); + keys[0].toBase58().should.equal(TEST_XPRV); + (bitgo.decryptAsync as sinon.SinonStub).callCount.should.equal(1); + }); + + it('rejects when requireBitGoXpub is true but bitgoKey is missing', async () => { + const bitgo = makeMockBitGo(() => { + throw new Error('should not be called'); + }); + await getBip32KeysAsync( + bitgo, + { userKey: TEST_XPRV, backupKey: TEST_XPRV, recoveryDestination: 'addr' }, + { requireBitGoXpub: true } + ).should.be.rejectedWith('BitGo xpub required but not provided'); + }); +});