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..bcc70b881d 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -91,6 +91,143 @@ import shamir = require('secrets.js-grempe'); import pjson = require('../package.json'); const debug = debugLib('bitgo:api'); +function validateDecryptKeysParams(params: DecryptKeysOptions): DecryptKeysOptions { + 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'); + } + + return params; +} + +function validateDecryptKeyPair(keyPair: { walletId: string; encryptedPrv: string }): void { + 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'); + } +} + +function validateSplitSecretInputs({ passwords, m }: SplitSecretOptions): number { + 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'); + } + + return passwords.length; +} + +function validateReconstituteInputs({ shards, passwords }: ReconstituteSecretOptions): void { + 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'); + } +} + +function buildSplitSecretResult(seed: string, shards: string[], m: number, n: number): SplitSecret { + const node = bip32.fromSeed(Buffer.from(seed, 'hex')); + return { + xpub: node.neutered().toBase58(), + m, + n, + seedShares: shards, + }; +} + +function buildReconstitutedSecret(seed: string): ReconstitutedSecret { + const node = bip32.fromSeed(Buffer.from(seed, 'hex')); + return { + xpub: node.neutered().toBase58() as string, + xprv: node.toBase58() as string, + seed, + }; +} + +function generateShardCombinations(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 = generateShardCombinations(array, m, currentEntryIndices); + combinations = [...combinations, ...newCombinations]; + } + + return combinations; +} + +function verifyShardSecrets(secrets: string[], m: number, xpub?: string): boolean { + const secretCombinations = generateShardCombinations(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; +} + +function deriveTokenIssuanceEcdhSecret(ecdhXprv: string, derivationPath: string, serverXpub: string): string { + const clientHDNode = bip32.fromBase58(ecdhXprv); + const serverHDNode = bip32.fromBase58(serverXpub); + const sanitizedPath = sanitizeLegacyPath(derivationPath); + const clientDerivedNode = clientHDNode.derivePath(sanitizedPath); + const serverDerivedNode = serverHDNode.derivePath(sanitizedPath); + const secretKey = clientDerivedNode.privateKey; + if (!secretKey) { + throw new Error('no client private Key'); + } + return 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(serverDerivedNode.publicKey, secretKey) + ).toString('hex'); +} + const Blockchain = require('./v1/blockchain'); const Keychains = require('./v1/keychains'); import Wallet = require('./v1/wallet'); @@ -783,6 +920,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 @@ -790,42 +928,52 @@ export class BitGoAPI implements BitGoBase { * @returns {string[]} - Array of wallet IDs for which decryption failed */ decryptKeys(params: DecryptKeysOptions): string[] { - params = params || {}; - if (!params.walletIdEncryptedKeyPairs) { - throw new Error('Missing parameter: walletIdEncryptedKeyPairs'); + const validatedParams = validateDecryptKeysParams(params); + if (validatedParams.walletIdEncryptedKeyPairs.length === 0) { + return []; } - if (!params.password) { - throw new Error('Missing parameter: password'); - } + const failedWalletIds: string[] = []; + + for (const keyPair of validatedParams.walletIdEncryptedKeyPairs) { + validateDecryptKeyPair(keyPair); - if (!Array.isArray(params.walletIdEncryptedKeyPairs)) { - throw new Error('walletIdEncryptedKeyPairs must be an array'); + try { + this.decrypt({ + input: keyPair.encryptedPrv, + password: validatedParams.password, + }); + // If no error was thrown, decryption was successful + } catch (error) { + // If decryption fails, add the walletId to the failed list + failedWalletIds.push(keyPair.walletId); + } } - if (params.walletIdEncryptedKeyPairs.length === 0) { + return failedWalletIds; + } + + /** + * Async version of decryptKeys with v2 encrypt/decrypt support. + * @param params + */ + async decryptKeysAsync(params: DecryptKeysOptions): Promise { + const validatedParams = validateDecryptKeysParams(params); + if (validatedParams.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'); - } + for (const keyPair of validatedParams.walletIdEncryptedKeyPairs) { + validateDecryptKeyPair(keyPair); try { - this.decrypt({ + await this.decryptAsync({ input: keyPair.encryptedPrv, - password: params.password, + password: validatedParams.password, }); - // If no error was thrown, decryption was successful } catch (error) { - // If decryption fails, add the walletId to the failed list failedWalletIds.push(keyPair.walletId); } } @@ -987,7 +1135,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 +1231,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 +1305,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 */ @@ -1185,44 +1333,71 @@ export class BitGoAPI implements BitGoBase { } } - // construct HDNode objects for client's xprv and server's xpub - const clientHDNode = bip32.fromBase58(ecdhXprv); - const serverHDNode = bip32.fromBase58(serverXpub); + const secret = deriveTokenIssuanceEcdhSecret(ecdhXprv, responseBody.derivationPath, 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); + try { + const token = this.decrypt({ + input: responseBody.encryptedToken, + password: secret, + }); + const response: TokenIssuance = { token }; + if (!this._ecdhXprv) { + response.ecdhXprv = ecdhXprv; + } + return response; + } catch (e) { + e.errorCode = 'token_decryption_failure'; + console.error('Failed to decrypt token.'); + throw e; + } + } - const publicKey = serverDerivedNode.publicKey; - const secretKey = clientDerivedNode.privateKey; - if (!secretKey) { - throw new Error('no client private Key'); + /** + * 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; + } } - 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; + const secret = deriveTokenIssuanceEcdhSecret(ecdhXprv, responseBody.derivationPath, serverXpub); + try { - response = { - token: this.decrypt({ - input: responseBody.encryptedToken, - password: secret, - }), - }; + const token = await this.decryptAsync({ + input: responseBody.encryptedToken, + password: secret, + }); + const response: TokenIssuance = { token }; + if (!this._ecdhXprv) { + response.ecdhXprv = ecdhXprv; + } + return response; } catch (e) { e.errorCode = 'token_decryption_failure'; console.error('Failed to decrypt token.'); throw e; } - if (!this._ecdhXprv) { - response.ecdhXprv = ecdhXprv; - } - return response; } /** @@ -1393,7 +1568,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,141 +1910,93 @@ 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 * @param m The threshold number of shards necessary to reconstitute the secret */ splitSecret({ seed, passwords, m }: SplitSecretOptions): SplitSecret { - 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 n = validateSplitSecretInputs({ seed, passwords, m }); const secrets: string[] = shamir.share(seed, n, m); const shards = _.zipWith(secrets, passwords, (shard, password) => { return this.encrypt({ input: shard, password }); }); - const node = bip32.fromSeed(Buffer.from(seed, 'hex')); - return { - xpub: node.neutered().toBase58(), - m, - n, - seedShares: shards, - }; + return buildSplitSecretResult(seed, shards, m, n); + } + + /** + * Async version of splitSecret with v2 encrypt/decrypt support. + * @param seed + * @param passwords + * @param m + */ + async splitSecretAsync({ seed, passwords, m }: SplitSecretOptions): Promise { + const n = validateSplitSecretInputs({ seed, passwords, m }); + const secrets: string[] = shamir.share(seed, n, m); + const shards = await Promise.all( + secrets.map((shard, i) => this.encryptAsync({ input: shard, password: passwords[i] })) + ); + return buildSplitSecretResult(seed, shards, m, n); } /** + * 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 */ reconstituteSecret({ shards, passwords }: ReconstituteSecretOptions): ReconstitutedSecret { - 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'); - } - + validateReconstituteInputs({ shards, passwords }); const secrets = _.zipWith(shards, passwords, (shard, password) => { return this.decrypt({ input: shard, password }); }); 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, - }; + return buildReconstitutedSecret(seed); } /** - * + * Async version of reconstituteSecret with v2 encrypt/decrypt support. + * @param shards + * @param passwords + */ + async reconstituteSecretAsync({ shards, passwords }: ReconstituteSecretOptions): Promise { + validateReconstituteInputs({ shards, passwords }); + const secrets = await Promise.all( + shards.map((shard, i) => this.decryptAsync({ input: shard, password: passwords[i] })) + ); + const seed: string = shamir.combine(secrets); + return buildReconstitutedSecret(seed); + } + + /** + * TODO: Deprecate this function in favour of verifyShardsAsync when v2 encryption is the default. * @param shards * @param passwords * @param m * @param xpub Optional xpub to verify the results against */ verifyShards({ shards, passwords, m, xpub }: VerifyShardsOptions): boolean { - /** - * Generate all possible combinations of a given array's values given subset size m - * @param array The array whose values are to be arranged in all combinations - * @param m The size of each subset - * @param entryIndices Recursively trailing set of currently chosen array indices for the combination subset under construction - * @returns {Array} - */ - const generateCombinations = (array: string[], m: number, entryIndices: number[] = []): string[][] => { - let combinations: string[][] = []; - - if (entryIndices.length === m) { - const currentCombination = _.at(array, entryIndices); - return [currentCombination]; - } - - // The highest index - let entryIndex = _.last(entryIndices); - // If there are currently no indices, assume -1 - if (_.isUndefined(entryIndex)) { - entryIndex = -1; - } - for (let i = entryIndex + 1; i < array.length; i++) { - // append the current index to the trailing indices - 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'); - } - + validateReconstituteInputs({ shards, passwords }); const secrets = _.zipWith(shards, passwords, (shard, password) => { return this.decrypt({ input: shard, password }); }); - 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 verifyShardSecrets(secrets, m, xpub); + } - 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 { + validateReconstituteInputs({ shards, passwords }); + const secrets = await Promise.all( + shards.map((shard, i) => this.decryptAsync({ input: shard, password: passwords[i] })) + ); + return verifyShardSecrets(secrets, m, xpub); } /** @@ -1914,7 +2041,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..5a23b2899d 100644 --- a/modules/sdk-api/src/v1/travelRule.ts +++ b/modules/sdk-api/src/v1/travelRule.ts @@ -10,21 +10,31 @@ // // Copyright 2014, BitGo, Inc. All Rights Reserved. // -import { common, getNetwork, getSharedSecret, makeRandomKey, sanitizeLegacyPath } from '@bitgo/sdk-core'; +import { + common, + DecryptFn, + DecryptFnAsync, + getNetwork, + getSharedSecret, + makeRandomKey, + sanitizeLegacyPath, +} from '@bitgo/sdk-core'; import { bip32, BIP32Interface } from '@bitgo/utxo-lib'; import * as utxolib from '@bitgo/utxo-lib'; import _ from 'lodash'; +interface ReceivedTravelInfoEntry { + toPubKeyPath: string; + fromPubKey: string; + encryptedTravelInfo: string; + travelInfo: string; + transactionId: string; + outputIndex: number; +} + interface DecryptReceivedTravelRuleOptions { tx?: { - receivedTravelInfo?: { - toPubKeyPath: string; - fromPubKey: string; - encryptedTravelInfo: string; - travelInfo: string; - transactionId: string; - outputIndex: number; - }[]; + receivedTravelInfo?: ReceivedTravelInfoEntry[]; }; keychain?: { xprv?: string; @@ -101,24 +111,14 @@ TravelRule.prototype.validateTravelInfo = function (info) { return result; }; -/** - * Takes a transaction object as returned by getTransaction or listTransactions, along - * with a keychain (or hdnode object), and attempts to decrypt any encrypted travel - * info included in the transaction's receivedTravelInfo field. - * Parameters: - * tx: a transaction object - * keychain: keychain object (with xprv) - * Returns: - * the tx object, augmented with decrypted travelInfo fields - */ -TravelRule.prototype.decryptReceivedTravelInfo = function (params: DecryptReceivedTravelRuleOptions = {}) { +function validateTravelRuleDecryptParams(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; + return { tx, hdNode: undefined as BIP32Interface | undefined }; } const keychain = params.keychain; @@ -126,25 +126,40 @@ TravelRule.prototype.decryptReceivedTravelInfo = function (params: DecryptReceiv throw new Error('expecting keychain param with xprv'); } const hdNode = bip32.fromBase58(keychain.xprv); + return { tx, hdNode }; +} - tx.receivedTravelInfo.forEach((info) => { - const key = hdNode.derivePath(sanitizeLegacyPath(info.toPubKeyPath)); - const secret = getSharedSecret(key, Buffer.from(info.fromPubKey, 'hex')).toString('hex'); - try { - const decrypted = this.bitgo.decrypt({ - 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); - } - }); +function decryptOneTravelInfoEntrySync( + info: ReceivedTravelInfoEntry, + hdNode: BIP32Interface, + decrypt: DecryptFn +): void { + const key = hdNode.derivePath(sanitizeLegacyPath(info.toPubKeyPath)); + const secret = getSharedSecret(key, Buffer.from(info.fromPubKey, 'hex')).toString('hex'); + try { + const decrypted = decrypt({ 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; -}; +async function decryptOneTravelInfoEntryAsync( + info: ReceivedTravelInfoEntry, + hdNode: BIP32Interface, + decryptAsync: DecryptFnAsync +): Promise { + const key = hdNode.derivePath(sanitizeLegacyPath(info.toPubKeyPath)); + const secret = getSharedSecret(key, Buffer.from(info.fromPubKey, 'hex')).toString('hex'); + try { + const decrypted = await 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); + } +} -TravelRule.prototype.prepareParams = function (params) { +function prepareTravelRuleParamsCommon(self: { validateTravelInfo: (info: any) => any }, params: any) { params = params || {}; params.txid = params.txid || params.hash; common.validateParams(params, ['txid'], ['fromPrivateInfo']); @@ -158,41 +173,118 @@ TravelRule.prototype.prepareParams = function (params) { throw new Error('invalid or missing travelInfo'); } if (!params.noValidate) { - travelInfo = this.validateTravelInfo(travelInfo); + travelInfo = self.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); + + return { + txid, + recipient, + travelInfoJSON, + fromKey, + sharedSecret, + fromPrivateInfo: params.fromPrivateInfo, + }; +} + +/** + * Takes a transaction object as returned by getTransaction or listTransactions, along + * with a keychain (or hdnode object), and attempts to decrypt any encrypted travel + * info included in the transaction's receivedTravelInfo field. + * Parameters: + * tx: a transaction object + * 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, hdNode } = validateTravelRuleDecryptParams(params); + if (!hdNode) { + return tx; + } + + tx!.receivedTravelInfo!.forEach((info) => { + decryptOneTravelInfoEntrySync(info, hdNode, (p) => this.bitgo.decrypt(p)); + }); + + return tx; +}; + +/** + * Async version of decryptReceivedTravelInfo with v2 encrypt/decrypt support. + */ +TravelRule.prototype.decryptReceivedTravelInfoAsync = async function (params: DecryptReceivedTravelRuleOptions = {}) { + const { tx, hdNode } = validateTravelRuleDecryptParams(params); + if (!hdNode) { + return tx; + } + + for (const info of tx!.receivedTravelInfo!) { + await decryptOneTravelInfoEntryAsync(info, hdNode, (p) => this.bitgo.decryptAsync(p)); + } + + return tx; +}; + +/** + * TODO: Deprecate in favor of prepareParamsAsync once v2 encryption is default. + */ +TravelRule.prototype.prepareParams = function (params) { + const prepared = prepareTravelRuleParamsCommon(this, params); const encryptedTravelInfo = this.bitgo.encrypt({ - input: travelInfoJSON, - password: sharedSecret, + input: prepared.travelInfoJSON, + password: prepared.sharedSecret, + }); + + const result = { + txid: prepared.txid, + outputIndex: prepared.recipient.outputIndex, + toPubKey: prepared.recipient.pubKey, + fromPubKey: prepared.fromKey.publicKey.toString('hex'), + encryptedTravelInfo: encryptedTravelInfo, + fromPrivateInfo: undefined as string | undefined, + }; + + if (prepared.fromPrivateInfo) { + result.fromPrivateInfo = prepared.fromPrivateInfo; + } + + return result; +}; + +/** + * Async version of prepareParams with v2 encrypt/decrypt support. + */ +TravelRule.prototype.prepareParamsAsync = async function (params) { + const prepared = prepareTravelRuleParamsCommon(this, params); + const encryptedTravelInfo = await this.bitgo.encryptAsync({ + input: prepared.travelInfoJSON, + password: prepared.sharedSecret, }); const result = { - txid: txid, - outputIndex: recipient.outputIndex, - toPubKey: recipient.pubKey, - fromPubKey: fromKey.publicKey.toString('hex'), + txid: prepared.txid, + outputIndex: prepared.recipient.outputIndex, + toPubKey: prepared.recipient.pubKey, + fromPubKey: prepared.fromKey.publicKey.toString('hex'), encryptedTravelInfo: encryptedTravelInfo, - fromPrivateInfo: undefined, + fromPrivateInfo: undefined as string | undefined, }; - if (params.fromPrivateInfo) { - result.fromPrivateInfo = params.fromPrivateInfo; + if (prepared.fromPrivateInfo) { + result.fromPrivateInfo = prepared.fromPrivateInfo; } return result; 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/api/types.ts b/modules/sdk-core/src/api/types.ts index 05a01afa10..b218b585ab 100644 --- a/modules/sdk-core/src/api/types.ts +++ b/modules/sdk-core/src/api/types.ts @@ -37,6 +37,16 @@ export interface EncryptOptions { encryptionVersion?: EncryptionVersion; } +/** Sync encrypt callback — used by v1 (SJCL) code paths. */ +export type EncryptFn = (params: { input: string; password: string }) => string; +/** Async encrypt callback — used by v2 (Argon2id) code paths. */ +export type EncryptFnAsync = (params: { input: string; password: string }) => Promise; + +/** Sync decrypt callback — used by v1 (SJCL) code paths. */ +export type DecryptFn = (params: { input: string; password?: string }) => string; +/** Async decrypt callback — used by v2 (Argon2id) code paths. */ +export type DecryptFnAsync = (params: { input: string; password?: string }) => Promise; + export interface GetSharingKeyOptions { email: string; } 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..e64dbb370e 100644 --- a/modules/sdk-core/src/bitgo/internal/keycard.ts +++ b/modules/sdk-core/src/bitgo/internal/keycard.ts @@ -6,6 +6,7 @@ */ import { isUndefined } from 'lodash'; import { Keychain } from '../keychain'; +import { EncryptFn, EncryptFnAsync } from '../../api'; /** * Return the list of questions that will appear on the second page of the keycard @@ -81,7 +82,7 @@ const generateQuestions = (coin: string) => { }; interface GetKeyDataOptions { - encrypt: (params: { input: string; password: string }) => string; + encrypt: EncryptFn; userKeychain: Keychain; bitgoKeychain: Keychain; backupKeychain: Keychain; @@ -92,40 +93,24 @@ interface GetKeyDataOptions { backupKeyID?: string; } -/** - * Collect all data which will go onto the keycard - * @param options - */ -function getKeyData(options: GetKeyDataOptions): any { - const { - encrypt, - userKeychain, - bitgoKeychain, - backupKeychain, - coinShortName, - passphrase, - passcodeEncryptionCode, - walletKeyID, - backupKeyID, - } = options; +type GetKeyDataAsyncOptions = Omit & { + encrypt: EncryptFnAsync; +}; - // 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 = encrypt({ - input: backupKeychain.prv, - password: passphrase, - }); - } +interface BuildKeycardQrDataOptions { + userKeychain: Keychain; + bitgoKeychain: Keychain; + backupKeychain: Keychain; + coinShortName: string; + walletKeyID?: string; + backupKeyID?: string; +} - // If we have the passcode encryption code, create a box D with the encryptedWalletPasscode - let encryptedWalletPasscode; - if (passphrase && passcodeEncryptionCode) { - encryptedWalletPasscode = encrypt({ - input: passphrase, - password: passcodeEncryptionCode, - }); - } +/** + * Build QR code payload for each keycard box. Does not perform encryption. + */ +function buildKeycardQrData(options: BuildKeycardQrDataOptions, encryptedWalletPasscode: string | undefined): any { + const { userKeychain, bitgoKeychain, backupKeychain, coinShortName, walletKeyID, backupKeyID } = options; // PDF QR Code data const qrData: any = { @@ -196,21 +181,82 @@ function getKeyData(options: GetKeyDataOptions): any { return qrData; } -interface DrawKeycardOptions extends GetKeyDataOptions { +/** + * TODO: Deprecate this function in favor of getKeyDataAsync once v2 encryption is default. + * Collect all data which will go onto the keycard + * @param options + */ +function getKeyData(options: GetKeyDataOptions): any { + const { encrypt, backupKeychain, passphrase, passcodeEncryptionCode, ...qrOptions } = 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 = encrypt({ + input: backupKeychain.prv, + password: passphrase, + }); + } + + // If we have the passcode encryption code, create a box D with the encryptedWalletPasscode + let encryptedWalletPasscode: string | undefined; + if (passphrase && passcodeEncryptionCode) { + encryptedWalletPasscode = encrypt({ + input: passphrase, + password: passcodeEncryptionCode, + }); + } + + return buildKeycardQrData({ ...qrOptions, backupKeychain }, encryptedWalletPasscode); +} + +/** + * Async version of getKeyData with support for v2 (Argon2id) encryption. + * Sequential encrypt: backup key first, then passcode (matches original getKeyDataAsync). + * @param options + */ +async function getKeyDataAsync(options: GetKeyDataAsyncOptions): Promise { + const { encrypt, backupKeychain, passphrase, passcodeEncryptionCode, ...qrOptions } = options; + + if (backupKeychain.prv && passphrase) { + backupKeychain.encryptedPrv = await encrypt({ + input: backupKeychain.prv, + password: passphrase, + }); + } + + let encryptedWalletPasscode: string | undefined; + if (passphrase && passcodeEncryptionCode) { + encryptedWalletPasscode = await encrypt({ + input: passphrase, + password: passcodeEncryptionCode, + }); + } + + return buildKeycardQrData({ ...qrOptions, backupKeychain }, encryptedWalletPasscode); +} + +interface DrawKeycardLayoutOptions { jsPDF: any; QRCode: any; - coinShortName: string; activationCode: string; walletLabel: string; coinName: string; } +interface DrawKeycardOptions extends GetKeyDataOptions, DrawKeycardLayoutOptions { + coinShortName: string; +} + +export type DrawKeycardAsyncOptions = Omit & { + encrypt: EncryptFnAsync; +}; + /** - * Draw a keycard into a new pdf document object - * @param options + * Render keycard PDF pages from pre-built QR data. */ -export function drawKeycard(options: DrawKeycardOptions): any { - const { jsPDF, QRCode, coinShortName, activationCode, walletLabel, coinName } = options; +function renderKeycardPdf(options: DrawKeycardLayoutOptions, keyData: any): any { + const { jsPDF, QRCode, activationCode, walletLabel, coinName } = options; const margin = 30; @@ -281,31 +327,6 @@ export function drawKeycard(options: DrawKeycardOptions): any { 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 = getKeyData({ - encrypt, - coinShortName, - passphrase, - passcodeEncryptionCode, - walletKeyID, - backupKeyID, - userKeychain, - bitgoKeychain, - backupKeychain, - }); - - // Generate the first page's data for the backup PDF moveDown(35); const qrSize = 130; @@ -382,3 +403,82 @@ export function drawKeycard(options: DrawKeycardOptions): any { return doc; } + +/** + * TODO: Deprecate this function in favor of drawKeycardAsync once v2 encryption is default. + * Draw a keycard into a new pdf document object + * @param options + */ +export function drawKeycard(options: DrawKeycardOptions): any { + const { + encrypt, + passphrase, + passcodeEncryptionCode, + walletKeyID, + backupKeyID, + userKeychain, + bitgoKeychain, + backupKeychain, + coinShortName, + jsPDF, + QRCode, + activationCode, + walletLabel, + coinName, + } = options; + + const keyData = getKeyData({ + encrypt, + coinShortName, + passphrase, + passcodeEncryptionCode, + walletKeyID, + backupKeyID, + userKeychain, + bitgoKeychain, + backupKeychain, + }); + + return renderKeycardPdf({ jsPDF, QRCode, activationCode, walletLabel, coinName }, keyData); +} + +/** + * 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 { + encrypt, + passphrase, + passcodeEncryptionCode, + walletKeyID, + backupKeyID, + userKeychain, + bitgoKeychain, + backupKeychain, + coinShortName, + jsPDF, + QRCode, + activationCode, + walletLabel, + coinName, + } = options; + + // Get the data for the first page (qr codes) + const keyData = await getKeyDataAsync({ + encrypt, + coinShortName, + passphrase, + passcodeEncryptionCode, + walletKeyID, + backupKeyID, + userKeychain, + bitgoKeychain, + backupKeychain, + }); + + return renderKeycardPdf({ jsPDF, QRCode, activationCode, walletLabel, coinName }, keyData); +} diff --git a/modules/sdk-core/src/bitgo/recovery/initiate.ts b/modules/sdk-core/src/bitgo/recovery/initiate.ts index e66e39ad49..d415632a82 100644 --- a/modules/sdk-core/src/bitgo/recovery/initiate.ts +++ b/modules/sdk-core/src/bitgo/recovery/initiate.ts @@ -101,6 +101,36 @@ export function getIsUnsignedSweep({ return backupKey.startsWith('xpub') && userKey.startsWith('xpub'); } +function parseKeyAsXprv(key: string, source: string): BIP32Interface { + try { + return bip32.fromBase58(key); + } catch (e) { + throw new Error(`Failed to validate ${source} key - try again!`); + } +} + +function addBitgoKeyIfRequired( + keys: BIP32Interface[], + params: InitiateRecoveryOptions | InitiateConsolidationRecoveryOptions, + requireBitGoXpub: boolean +): BIP32Interface[] { + 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; +} + +/** + * 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 @@ -115,13 +145,31 @@ export function validateKey( 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!`); + return parseKeyAsXprv(key, source); +} + +/** + * 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!`); + } } + return parseKeyAsXprv(key, source); } +/** + * TODO: Deprecate this function in favour of getBip32KeysAsync when v2 encryption is the default. + */ export function getBip32Keys( bitgo: BitGoBase, params: InitiateRecoveryOptions | InitiateConsolidationRecoveryOptions, @@ -129,36 +177,42 @@ export function getBip32Keys( ): BIP32Interface[] { const isKrsRecovery = getIsKrsRecovery(params); const isUnsignedSweep = getIsUnsignedSweep(params); + const validateKeyOpts = { + passphrase: params.walletPassphrase, + isKrsRecovery, + isUnsignedSweep, + }; const keys = [ // Box A - validateKey(bitgo, { - key: params.userKey, - source: 'user', - passphrase: params.walletPassphrase, - isKrsRecovery, - isUnsignedSweep, - }), + validateKey(bitgo, { key: params.userKey, source: 'user', ...validateKeyOpts }), // Box B - validateKey(bitgo, { - key: params.backupKey, - source: 'backup', - passphrase: params.walletPassphrase, - isKrsRecovery, - isUnsignedSweep, - }), + validateKey(bitgo, { key: params.backupKey, source: 'backup', ...validateKeyOpts }), ]; - 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 addBitgoKeyIfRequired(keys, params, requireBitGoXpub); +} - 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 validateKeyOpts = { + passphrase: params.walletPassphrase, + isKrsRecovery, + isUnsignedSweep, + }; + const keys = [ + // Box A — sequential: backup only after user completes + await validateKeyAsync(bitgo, { key: params.userKey, source: 'user', ...validateKeyOpts }), + // Box B + await validateKeyAsync(bitgo, { key: params.backupKey, source: 'backup', ...validateKeyOpts }), + ]; + + return addBitgoKeyIfRequired(keys, params, requireBitGoXpub); } 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'); + }); +});