From 85dccff61c3b0626d5d03acbcf8eb9518ea19d04 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Wed, 8 Jul 2026 19:54:02 +0000 Subject: [PATCH] fix(sdk-core): throw NeedUserSignupError when recipient has no ECDH pubkey When a user tries to share a wallet with a recipient whose ECDH keychain has not been initialized (pubkey is empty or missing from getSharingKey response), the previous code would proceed to decrypt the sharer's keychain and then fail with a cryptographic assertion error. The UI surface this as a misleading "wallet password incorrect" error. The fix adds a pubkey presence check inside prepareSharedKeychain, after confirming the wallet has an encrypted keychain to share. Cold wallets (no encryptedPrv) still silently skip keychain sharing as before. Ticket: POL-41 Session-Id: 2c2322e0-26a5-4458-974b-16c52d93e7e3 Task-Id: 372e9c2b-34e3-4aed-aaa1-62bc2c038f73 --- modules/bitgo/test/v2/unit/wallet.ts | 80 +++++++++++++++++++++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 13 +++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 761fb25056..6d11826911 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -21,6 +21,7 @@ import { ManageUnspentsOptions, MessageStandardType, MessageTypes, + NeedUserSignupError, PopulatedIntent, PrebuildTransactionOptions, PrebuildTransactionWithIntentOptions, @@ -2144,6 +2145,85 @@ describe('V2 Wallet:', function () { getKeyNock.isDone().should.be.True(); }); + it('should throw NeedUserSignupError when recipient has no ECDH pubkey (spend permission)', async function () { + const userId = '456'; + const email = 'nopubkey@sdktest.com'; + const permissions = 'view,spend'; + const walletPassphrase = 'bitgo1234'; + const pub = 'Zo1ggzTUKMY5bYnDvT5mtVeZxzf2FaLTbKkmvGUhUQk'; + const encXprv = await bitgo.encrypt({ input: 'xprv1', password: walletPassphrase }); + + // Server returns empty pubkey — user has not completed ECDH keychain setup + const getSharingKeyNock = nock(bgUrl) + .post('/api/v1/user/sharingkey', { email }) + .reply(200, { userId, pubkey: '', path: 'm/999999/1/1' }); + + // Key fetch must succeed so decryption can proceed before the pubkey check + const getKeyNock = nock(bgUrl).get(`/api/v2/tbtc/key/${wallet.keyIds()[0]}`).reply(200, { + id: wallet.keyIds()[0], + pub, + source: 'user', + encryptedPrv: encXprv, + coinSpecific: {}, + }); + + await wallet + .shareWallet({ email, permissions, walletPassphrase }) + .should.be.rejectedWith(NeedUserSignupError); + + getSharingKeyNock.isDone().should.be.True(); + getKeyNock.isDone().should.be.True(); + }); + + it('should throw NeedUserSignupError when recipient pubkey is missing (spend permission)', async function () { + const userId = '456'; + const email = 'nopubkey@sdktest.com'; + const permissions = 'view,spend'; + const walletPassphrase = 'bitgo1234'; + const pub = 'Zo1ggzTUKMY5bYnDvT5mtVeZxzf2FaLTbKkmvGUhUQk'; + const encXprv = await bitgo.encrypt({ input: 'xprv1', password: walletPassphrase }); + + // Server returns no pubkey field at all + const getSharingKeyNock = nock(bgUrl) + .post('/api/v1/user/sharingkey', { email }) + .reply(200, { userId, path: 'm/999999/1/1' }); + + const getKeyNock = nock(bgUrl).get(`/api/v2/tbtc/key/${wallet.keyIds()[0]}`).reply(200, { + id: wallet.keyIds()[0], + pub, + source: 'user', + encryptedPrv: encXprv, + coinSpecific: {}, + }); + + await wallet + .shareWallet({ email, permissions, walletPassphrase }) + .should.be.rejectedWith(NeedUserSignupError); + + getSharingKeyNock.isDone().should.be.True(); + getKeyNock.isDone().should.be.True(); + }); + + it('should not throw NeedUserSignupError when recipient has no pubkey but permission is view-only', async function () { + const userId = '456'; + const email = 'nopubkey@sdktest.com'; + const permissions = 'view'; + + // No pubkey — but view-only shares do not need keychain, so should succeed + const getSharingKeyNock = nock(bgUrl) + .post('/api/v1/user/sharingkey', { email }) + .reply(200, { userId, pubkey: '', path: 'm/999999/1/1' }); + + const createShareNock = nock(bgUrl) + .post(`/api/v2/tbtc/wallet/${wallet.id()}/share`) + .reply(200, {}); + + await wallet.shareWallet({ email, permissions }); + + getSharingKeyNock.isDone().should.be.True(); + createShareNock.isDone().should.be.True(); + }); + describe('OFC multi-key-user-Key Wallet Sharing', function () { const userId = '123'; const email = 'shareto@sdktest.com'; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index a8c1952cf5..32385e0d3a 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2005,13 +2005,21 @@ export class Wallet implements IWallet { walletPassphrase: string | undefined, pubkey: string, path: string, - encryptionVersion?: EncryptionVersion + encryptionVersion?: EncryptionVersion, + recipientEmail?: string ): Promise { try { const decryptedKeychain = await this.getDecryptedKeychainForSharing(walletPassphrase); if (!decryptedKeychain) { return {}; } + if (!pubkey) { + const recipient = recipientEmail ?? 'The recipient'; + throw new NeedUserSignupError( + `${recipient} does not have an ECDH keychain set up. ` + + `They must log into the BitGo web application at least once before receiving a wallet share.` + ); + } return await this.encryptPrvForUser( decryptedKeychain.prv, decryptedKeychain.pub, @@ -2068,7 +2076,8 @@ export class Wallet implements IWallet { params.walletPassphrase, sharing.pubkey, sharing.path, - params.encryptionVersion + params.encryptionVersion, + params.email ); }