diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index e3d3d750d94..9bb9be45ea6 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -1882,7 +1882,9 @@ export class BitGoAPI implements BitGoBase { if (!ecdhKeychainPub) { const result = await this.get(this.url('/user/settings')).result(); if (!result.settings.ecdhKeychain) { - return new Error('ecdh keychain not found for user'); + throw new Error( + 'Your account is missing the sharing key needed for this action. Please contact support@bitgo.com so it can be set up.' + ); } ecdhKeychainPub = result.settings.ecdhKeychain; } diff --git a/modules/sdk-api/test/unit/bitgoAPI.ts b/modules/sdk-api/test/unit/bitgoAPI.ts index 4f9dbed15f6..6caa60494f2 100644 --- a/modules/sdk-api/test/unit/bitgoAPI.ts +++ b/modules/sdk-api/test/unit/bitgoAPI.ts @@ -1562,3 +1562,24 @@ describe('wallets() v1 facade', function () { wallets.resendShareInvite.should.be.a.Function(); }); }); + +describe('getECDHKeychain', function () { + const ROOT = 'https://app.example.local'; + + afterEach(function () { + nock.cleanAll(); + }); + + it('rejects when the user has no ecdhKeychain in settings', async function () { + const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT }); + nock(ROOT) + .get('/api/v1/user/settings') + .reply(200, { settings: { ecdhKeychain: null } }); + + await bitgo + .getECDHKeychain() + .should.be.rejectedWith( + 'Your account is missing the sharing key needed for this action. Please contact support@bitgo.com so it can be set up.' + ); + }); +}); diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 329d1405fb2..618c3f1daaf 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -1358,15 +1358,18 @@ export class Wallets implements IWallets { throw new Error('No valid wallet shares found to accept'); } - const sharingKeychain = await this.bitgo.getECDHKeychain(); - if (_.isUndefined(sharingKeychain.encryptedXprv)) { - throw new Error('encryptedXprv was not found on sharing keychain'); - } + let sharingKeychainPrv: string | undefined; + if (walletShares.some((walletShare) => !walletShare.userMultiKeyRotationRequired && walletShare.keychain)) { + const sharingKeychain = await this.bitgo.getECDHKeychain(); + if (_.isUndefined(sharingKeychain.encryptedXprv)) { + throw new Error('encryptedXprv was not found on sharing keychain'); + } - sharingKeychain.prv = await this.bitgo.decrypt({ - password: params.userLoginPassword, - input: sharingKeychain.encryptedXprv, - }); + sharingKeychainPrv = await this.bitgo.decrypt({ + password: params.userLoginPassword, + input: sharingKeychain.encryptedXprv, + }); + } const newWalletPassphrase = params.newWalletPassphrase || params.userLoginPassword; const webauthnInfo = params.webauthnInfo; @@ -1409,8 +1412,12 @@ export class Wallets implements IWallets { if (!walletShare.keychain) { return []; } + assert( + sharingKeychainPrv, + 'Unable to accept this wallet share because your sharing key could not be loaded. Please try again, or contact support@bitgo.com if the problem continues.' + ); const secret = getSharedSecret( - bip32.fromBase58(sharingKeychain.prv).derivePath(sanitizeLegacyPath(walletShare.keychain.path)), + bip32.fromBase58(sharingKeychainPrv).derivePath(sanitizeLegacyPath(walletShare.keychain.path)), Buffer.from(walletShare.keychain.fromPubKey, 'hex') ).toString('hex'); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsEncryptionVersion.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsEncryptionVersion.ts index cf08425c7b8..7519cc2f70f 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/walletsEncryptionVersion.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsEncryptionVersion.ts @@ -145,6 +145,50 @@ describe('Wallets - encryptionVersion threading', function () { assert.strictEqual(call.args[1], undefined); }); + it('accepts a UMKR-only batch without fetching the ECDH keychain', async function () { + mockBitGo.getECDHKeychain.resolves(new Error('missing')); + + await wallets.bulkAcceptShare({ + walletShareIds: ['share-id'], + userLoginPassword: 'login-password', + }); + + assert.strictEqual(mockBitGo.getECDHKeychain.called, false); + assert.strictEqual(mockBitGo.put.called, true); + }); + + it('still requires the ECDH keychain when the batch also holds a share with a keychain', async function () { + mockBitGo.get.returns({ + result: sinon.stub().resolves({ + incoming: [ + ...walletSharesList.incoming, + { + id: 'keychain-share-id', + keychain: { + pub: 'pub', + toPubKey: 'toPubKey', + fromPubKey: 'fromPubKey', + encryptedPrv: 'prv', + path: 'm/0', + }, + permissions: ['spend'], + }, + ], + outgoing: [], + }), + }); + mockBitGo.getECDHKeychain.resolves({}); + + await assert.rejects( + wallets.bulkAcceptShare({ + walletShareIds: ['share-id', 'keychain-share-id'], + userLoginPassword: 'login-password', + }), + { message: 'encryptedXprv was not found on sharing keychain' } + ); + assert.strictEqual(mockBitGo.getECDHKeychain.calledOnce, true); + }); + it('processes shares in batches of 16 to avoid WASM memory exhaustion', async function () { // 20 shares → 2 batches (16 + 4), verifying the batch boundary is crossed const manyShares = Array.from({ length: 20 }, (_, i) => ({