From ada11fd3095658f84eb1f319114ddf233b6b195d Mon Sep 17 00:00:00 2001 From: Derran Wijesinghe Date: Fri, 27 Mar 2026 13:29:04 -0400 Subject: [PATCH] feat(sdk-core): add webauthnInfo support to bulkAcceptShare When webauthnInfo is provided, each share entry now includes a second encrypted copy of the wallet private key using the PRF-derived passphrase, alongside the standard password-encrypted copy. The passphrase is consumed client-side only and never sent to the server. Ticket: WP-8314 --- modules/bitgo/test/v2/unit/wallets.ts | 212 ++++++++++++++++++ modules/sdk-core/src/bitgo/wallet/iWallets.ts | 12 + modules/sdk-core/src/bitgo/wallet/wallets.ts | 45 ++-- 3 files changed, 256 insertions(+), 13 deletions(-) diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index 31809216d0..4be6b1162c 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -2504,6 +2504,218 @@ describe('V2 Wallets:', function () { }); }); + it('should include webauthnInfo in request when provided (ECDH branch)', async () => { + const fromUserPrv = Math.random(); + const walletPassphrase = 'bitgo1234'; + const webauthnPassphrase = 'prf-derived-secret'; + const shareId = '66a229dbdccdcfb95b44fc2745a60bd4'; + const keychainTest: OptionalKeychainEncryptedKey = { + encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }), + }; + const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); + if (!userPrv) { + throw new Error('Unable to decrypt user keychain'); + } + + const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex')); + const path = 'm/999999/1/1'; + const pubkey = toKeychain.derivePath(path).publicKey.toString('hex'); + + const eckey = makeRandomKey(); + const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex'); + const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv }); + + let capturedBody: any; + nock(bgUrl) + .get('/api/v2/walletshares') + .reply(200, { + incoming: [ + { + id: shareId, + isUMSInitiated: true, + keychain: { + path: path, + fromPubKey: eckey.publicKey.toString('hex'), + encryptedPrv: newEncryptedPrv, + toPubKey: pubkey, + pub: pubkey, + }, + }, + ], + }); + nock(bgUrl) + .put('/api/v2/walletshares/accept', (body) => { + capturedBody = body; + return true; + }) + .reply(200, { + acceptedWalletShares: [{ walletShareId: shareId }], + }); + + const myEcdhKeychain = await bitgo.keychains().create(); + sinon.stub(bitgo, 'getECDHKeychain').resolves({ + encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }), + }); + + const prvKey = bitgo.decrypt({ + password: walletPassphrase, + input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }), + }); + sinon.stub(bitgo, 'decrypt').returns(prvKey); + sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret'); + + await wallets.bulkAcceptShare({ + walletShareIds: [shareId], + userLoginPassword: walletPassphrase, + webauthnInfo: { + otpDeviceId: 'device-001', + prfSalt: 'salt-abc', + passphrase: webauthnPassphrase, + }, + }); + + const sentEntries = capturedBody.keysForWalletShares; + sentEntries.should.have.length(1); + sentEntries[0].should.have.property('encryptedPrv'); + sentEntries[0].should.have.property('webauthnInfo'); + sentEntries[0].webauthnInfo.should.have.property('otpDeviceId', 'device-001'); + sentEntries[0].webauthnInfo.should.have.property('prfSalt', 'salt-abc'); + sentEntries[0].webauthnInfo.should.have.property('encryptedPrv'); + sentEntries[0].webauthnInfo.should.not.have.property('passphrase'); + }); + + it('should include webauthnInfo in request when provided (userMultiKeyRotationRequired branch)', async () => { + const walletPassphrase = 'bitgo1234'; + const webauthnPassphrase = 'prf-derived-secret'; + const shareId = 'multi-key-share-id-001'; + + sinon.stub(Wallets.prototype, 'listSharesV2').resolves({ + incoming: [ + { + id: shareId, + coin: 'tsol', + walletLabel: 'testing', + fromUser: 'dummyFromUser', + toUser: 'dummyToUser', + wallet: 'dummyWalletId', + permissions: ['spend'], + state: 'active', + userMultiKeyRotationRequired: true, + }, + ], + outgoing: [], + }); + + const myEcdhKeychain = await bitgo.keychains().create(); + sinon.stub(bitgo, 'getECDHKeychain').resolves({ + encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }), + }); + const prvKey = bitgo.decrypt({ + password: walletPassphrase, + input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }), + }); + sinon.stub(bitgo, 'decrypt').returns(prvKey); + + let capturedBody: any; + nock(bgUrl) + .put('/api/v2/walletshares/accept', (body) => { + capturedBody = body; + return true; + }) + .reply(200, { + acceptedWalletShares: [{ walletShareId: shareId }], + }); + + await wallets.bulkAcceptShare({ + walletShareIds: [shareId], + userLoginPassword: walletPassphrase, + webauthnInfo: { + otpDeviceId: 'device-002', + prfSalt: 'salt-xyz', + passphrase: webauthnPassphrase, + }, + }); + + const sentEntries = capturedBody.keysForWalletShares; + sentEntries.should.have.length(1); + sentEntries[0].should.have.property('pub'); + sentEntries[0].should.have.property('encryptedPrv'); + sentEntries[0].should.have.property('webauthnInfo'); + sentEntries[0].webauthnInfo.should.have.property('otpDeviceId', 'device-002'); + sentEntries[0].webauthnInfo.should.have.property('prfSalt', 'salt-xyz'); + sentEntries[0].webauthnInfo.should.have.property('encryptedPrv'); + sentEntries[0].webauthnInfo.should.not.have.property('passphrase'); + }); + + it('should NOT include webauthnInfo when not provided (backward compat)', async () => { + const fromUserPrv = Math.random(); + const walletPassphrase = 'bitgo1234'; + const shareId = '66a229dbdccdcfb95b44fc2745a60bd4'; + const keychainTest: OptionalKeychainEncryptedKey = { + encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }), + }; + const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase); + if (!userPrv) { + throw new Error('Unable to decrypt user keychain'); + } + + const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex')); + const path = 'm/999999/1/1'; + const pubkey = toKeychain.derivePath(path).publicKey.toString('hex'); + + const eckey = makeRandomKey(); + const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex'); + const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv }); + + let capturedBody: any; + nock(bgUrl) + .get('/api/v2/walletshares') + .reply(200, { + incoming: [ + { + id: shareId, + isUMSInitiated: true, + keychain: { + path: path, + fromPubKey: eckey.publicKey.toString('hex'), + encryptedPrv: newEncryptedPrv, + toPubKey: pubkey, + pub: pubkey, + }, + }, + ], + }); + nock(bgUrl) + .put('/api/v2/walletshares/accept', (body) => { + capturedBody = body; + return true; + }) + .reply(200, { + acceptedWalletShares: [{ walletShareId: shareId }], + }); + + const myEcdhKeychain = await bitgo.keychains().create(); + sinon.stub(bitgo, 'getECDHKeychain').resolves({ + encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }), + }); + const prvKey = bitgo.decrypt({ + password: walletPassphrase, + input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }), + }); + sinon.stub(bitgo, 'decrypt').returns(prvKey); + sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret'); + + await wallets.bulkAcceptShare({ + walletShareIds: [shareId], + userLoginPassword: walletPassphrase, + }); + + const sentEntries = capturedBody.keysForWalletShares; + sentEntries.should.have.length(1); + sentEntries[0].should.have.property('encryptedPrv'); + sentEntries[0].should.not.have.property('webauthnInfo'); + }); + it('should handle 413 payload too large error with smart retry', async () => { const walletPassphrase = 'bitgo1234'; const fromUserPrv = Math.random(); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index 994eec9a31..c5d5b7fba7 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -134,10 +134,17 @@ export interface AcceptShareOptions { newWalletPassphrase?: string; } +export interface AcceptShareWebauthnInfo { + otpDeviceId: string; + prfSalt: string; + passphrase: string; +} + export interface BulkAcceptShareOptions { walletShareIds: string[]; userLoginPassword: string; newWalletPassphrase?: string; + webauthnInfo?: AcceptShareWebauthnInfo; } export interface AcceptShareOptionsRequest { @@ -148,6 +155,11 @@ export interface AcceptShareOptionsRequest { * Required for userMultiKeyRotationRequired shares. */ pub?: string; + webauthnInfo?: { + otpDeviceId: string; + prfSalt: string; + encryptedPrv: string; + }; } export interface BulkUpdateWalletShareOptions { diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 0593d44295..a48930134d 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -1055,6 +1055,7 @@ export class Wallets implements IWallets { input: sharingKeychain.encryptedXprv, }); const newWalletPassphrase = params.newWalletPassphrase || params.userLoginPassword; + const webauthnInfo = params.webauthnInfo; const keysForWalletShares = walletShares.flatMap((walletShare) => { // Handle userMultiKeyRotationRequired case - these shares don't have keychains if (walletShare.userMultiKeyRotationRequired) { @@ -1066,13 +1067,22 @@ export class Wallets implements IWallets { password: newWalletPassphrase, input: walletKeychain.prv, }); - return [ - { - walletShareId: walletShare.id, - encryptedPrv: encryptedPrv, - pub: walletKeychain.pub, - }, - ]; + const entry: AcceptShareOptionsRequest = { + walletShareId: walletShare.id, + encryptedPrv: encryptedPrv, + pub: walletKeychain.pub, + }; + if (webauthnInfo) { + entry.webauthnInfo = { + otpDeviceId: webauthnInfo.otpDeviceId, + prfSalt: webauthnInfo.prfSalt, + encryptedPrv: this.bitgo.encrypt({ + password: webauthnInfo.passphrase, + input: walletKeychain.prv, + }), + }; + } + return [entry]; } // Standard case: shares with keychains @@ -1092,12 +1102,21 @@ export class Wallets implements IWallets { password: newWalletPassphrase, input: decryptedSharedWalletPrv, }); - return [ - { - walletShareId: walletShare.id, - encryptedPrv: newEncryptedPrv, - }, - ]; + const entry: AcceptShareOptionsRequest = { + walletShareId: walletShare.id, + encryptedPrv: newEncryptedPrv, + }; + if (webauthnInfo) { + entry.webauthnInfo = { + otpDeviceId: webauthnInfo.otpDeviceId, + prfSalt: webauthnInfo.prfSalt, + encryptedPrv: this.bitgo.encrypt({ + password: webauthnInfo.passphrase, + input: decryptedSharedWalletPrv, + }), + }; + } + return [entry]; }); return this.bulkAcceptShareRequest(keysForWalletShares);