Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
21 changes: 21 additions & 0 deletions modules/sdk-api/test/unit/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
);
});
});
25 changes: 16 additions & 9 deletions modules/sdk-core/src/bitgo/wallet/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
Loading