Skip to content

Commit 0d54fd1

Browse files
committed
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
1 parent 7ac007d commit 0d54fd1

3 files changed

Lines changed: 258 additions & 13 deletions

File tree

modules/bitgo/test/v2/unit/wallets.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2504,6 +2504,218 @@ describe('V2 Wallets:', function () {
25042504
});
25052505
});
25062506

2507+
it('should include webauthnInfo in request when provided (ECDH branch)', async () => {
2508+
const fromUserPrv = Math.random();
2509+
const walletPassphrase = 'bitgo1234';
2510+
const webauthnPassphrase = 'prf-derived-secret';
2511+
const shareId = '66a229dbdccdcfb95b44fc2745a60bd4';
2512+
const keychainTest: OptionalKeychainEncryptedKey = {
2513+
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
2514+
};
2515+
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
2516+
if (!userPrv) {
2517+
throw new Error('Unable to decrypt user keychain');
2518+
}
2519+
2520+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
2521+
const path = 'm/999999/1/1';
2522+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
2523+
2524+
const eckey = makeRandomKey();
2525+
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
2526+
const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv });
2527+
2528+
let capturedBody: any;
2529+
nock(bgUrl)
2530+
.get('/api/v2/walletshares')
2531+
.reply(200, {
2532+
incoming: [
2533+
{
2534+
id: shareId,
2535+
isUMSInitiated: true,
2536+
keychain: {
2537+
path: path,
2538+
fromPubKey: eckey.publicKey.toString('hex'),
2539+
encryptedPrv: newEncryptedPrv,
2540+
toPubKey: pubkey,
2541+
pub: pubkey,
2542+
},
2543+
},
2544+
],
2545+
});
2546+
nock(bgUrl)
2547+
.put('/api/v2/walletshares/accept', (body) => {
2548+
capturedBody = body;
2549+
return true;
2550+
})
2551+
.reply(200, {
2552+
acceptedWalletShares: [{ walletShareId: shareId }],
2553+
});
2554+
2555+
const myEcdhKeychain = await bitgo.keychains().create();
2556+
sinon.stub(bitgo, 'getECDHKeychain').resolves({
2557+
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2558+
});
2559+
2560+
const prvKey = bitgo.decrypt({
2561+
password: walletPassphrase,
2562+
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2563+
});
2564+
sinon.stub(bitgo, 'decrypt').returns(prvKey);
2565+
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
2566+
2567+
await wallets.bulkAcceptShare({
2568+
walletShareIds: [shareId],
2569+
userLoginPassword: walletPassphrase,
2570+
webauthnInfo: {
2571+
otpDeviceId: 'device-001',
2572+
prfSalt: 'salt-abc',
2573+
passphrase: webauthnPassphrase,
2574+
},
2575+
});
2576+
2577+
const sentEntries = capturedBody.keysForWalletShares;
2578+
sentEntries.should.have.length(1);
2579+
sentEntries[0].should.have.property('encryptedPrv');
2580+
sentEntries[0].should.have.property('webauthnInfo');
2581+
sentEntries[0].webauthnInfo.should.have.property('otpDeviceId', 'device-001');
2582+
sentEntries[0].webauthnInfo.should.have.property('prfSalt', 'salt-abc');
2583+
sentEntries[0].webauthnInfo.should.have.property('encryptedPrv');
2584+
sentEntries[0].webauthnInfo.should.not.have.property('passphrase');
2585+
});
2586+
2587+
it('should include webauthnInfo in request when provided (userMultiKeyRotationRequired branch)', async () => {
2588+
const walletPassphrase = 'bitgo1234';
2589+
const webauthnPassphrase = 'prf-derived-secret';
2590+
const shareId = 'multi-key-share-id-001';
2591+
2592+
sinon.stub(Wallets.prototype, 'listSharesV2').resolves({
2593+
incoming: [
2594+
{
2595+
id: shareId,
2596+
coin: 'tsol',
2597+
walletLabel: 'testing',
2598+
fromUser: 'dummyFromUser',
2599+
toUser: 'dummyToUser',
2600+
wallet: 'dummyWalletId',
2601+
permissions: ['spend'],
2602+
state: 'active',
2603+
userMultiKeyRotationRequired: true,
2604+
},
2605+
],
2606+
outgoing: [],
2607+
});
2608+
2609+
const myEcdhKeychain = await bitgo.keychains().create();
2610+
sinon.stub(bitgo, 'getECDHKeychain').resolves({
2611+
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2612+
});
2613+
const prvKey = bitgo.decrypt({
2614+
password: walletPassphrase,
2615+
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2616+
});
2617+
sinon.stub(bitgo, 'decrypt').returns(prvKey);
2618+
2619+
let capturedBody: any;
2620+
nock(bgUrl)
2621+
.put('/api/v2/walletshares/accept', (body) => {
2622+
capturedBody = body;
2623+
return true;
2624+
})
2625+
.reply(200, {
2626+
acceptedWalletShares: [{ walletShareId: shareId }],
2627+
});
2628+
2629+
await wallets.bulkAcceptShare({
2630+
walletShareIds: [shareId],
2631+
userLoginPassword: walletPassphrase,
2632+
webauthnInfo: {
2633+
otpDeviceId: 'device-002',
2634+
prfSalt: 'salt-xyz',
2635+
passphrase: webauthnPassphrase,
2636+
},
2637+
});
2638+
2639+
const sentEntries = capturedBody.keysForWalletShares;
2640+
sentEntries.should.have.length(1);
2641+
sentEntries[0].should.have.property('pub');
2642+
sentEntries[0].should.have.property('encryptedPrv');
2643+
sentEntries[0].should.have.property('webauthnInfo');
2644+
sentEntries[0].webauthnInfo.should.have.property('otpDeviceId', 'device-002');
2645+
sentEntries[0].webauthnInfo.should.have.property('prfSalt', 'salt-xyz');
2646+
sentEntries[0].webauthnInfo.should.have.property('encryptedPrv');
2647+
sentEntries[0].webauthnInfo.should.not.have.property('passphrase');
2648+
});
2649+
2650+
it('should NOT include webauthnInfo when not provided (backward compat)', async () => {
2651+
const fromUserPrv = Math.random();
2652+
const walletPassphrase = 'bitgo1234';
2653+
const shareId = '66a229dbdccdcfb95b44fc2745a60bd4';
2654+
const keychainTest: OptionalKeychainEncryptedKey = {
2655+
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
2656+
};
2657+
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
2658+
if (!userPrv) {
2659+
throw new Error('Unable to decrypt user keychain');
2660+
}
2661+
2662+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
2663+
const path = 'm/999999/1/1';
2664+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
2665+
2666+
const eckey = makeRandomKey();
2667+
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
2668+
const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv });
2669+
2670+
let capturedBody: any;
2671+
nock(bgUrl)
2672+
.get('/api/v2/walletshares')
2673+
.reply(200, {
2674+
incoming: [
2675+
{
2676+
id: shareId,
2677+
isUMSInitiated: true,
2678+
keychain: {
2679+
path: path,
2680+
fromPubKey: eckey.publicKey.toString('hex'),
2681+
encryptedPrv: newEncryptedPrv,
2682+
toPubKey: pubkey,
2683+
pub: pubkey,
2684+
},
2685+
},
2686+
],
2687+
});
2688+
nock(bgUrl)
2689+
.put('/api/v2/walletshares/accept', (body) => {
2690+
capturedBody = body;
2691+
return true;
2692+
})
2693+
.reply(200, {
2694+
acceptedWalletShares: [{ walletShareId: shareId }],
2695+
});
2696+
2697+
const myEcdhKeychain = await bitgo.keychains().create();
2698+
sinon.stub(bitgo, 'getECDHKeychain').resolves({
2699+
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2700+
});
2701+
const prvKey = bitgo.decrypt({
2702+
password: walletPassphrase,
2703+
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2704+
});
2705+
sinon.stub(bitgo, 'decrypt').returns(prvKey);
2706+
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
2707+
2708+
await wallets.bulkAcceptShare({
2709+
walletShareIds: [shareId],
2710+
userLoginPassword: walletPassphrase,
2711+
});
2712+
2713+
const sentEntries = capturedBody.keysForWalletShares;
2714+
sentEntries.should.have.length(1);
2715+
sentEntries[0].should.have.property('encryptedPrv');
2716+
sentEntries[0].should.not.have.property('webauthnInfo');
2717+
});
2718+
25072719
it('should handle 413 payload too large error with smart retry', async () => {
25082720
const walletPassphrase = 'bitgo1234';
25092721
const fromUserPrv = Math.random();

modules/sdk-core/src/bitgo/wallet/iWallets.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,18 @@ export interface AcceptShareOptions {
134134
newWalletPassphrase?: string;
135135
}
136136

137+
export interface AcceptShareWebauthnInfo {
138+
otpDeviceId: string;
139+
prfSalt: string;
140+
/** PRF-derived password — used client-side for encryption, never sent to server. */
141+
passphrase: string;
142+
}
143+
137144
export interface BulkAcceptShareOptions {
138145
walletShareIds: string[];
139146
userLoginPassword: string;
140147
newWalletPassphrase?: string;
148+
webauthnInfo?: AcceptShareWebauthnInfo;
141149
}
142150

143151
export interface AcceptShareOptionsRequest {
@@ -148,6 +156,12 @@ export interface AcceptShareOptionsRequest {
148156
* Required for userMultiKeyRotationRequired shares.
149157
*/
150158
pub?: string;
159+
/** WebAuthn device metadata with prv encrypted to PRF-derived passphrase. */
160+
webauthnInfo?: {
161+
otpDeviceId: string;
162+
prfSalt: string;
163+
encryptedPrv: string;
164+
};
151165
}
152166

153167
export interface BulkUpdateWalletShareOptions {

modules/sdk-core/src/bitgo/wallet/wallets.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,7 @@ export class Wallets implements IWallets {
10551055
input: sharingKeychain.encryptedXprv,
10561056
});
10571057
const newWalletPassphrase = params.newWalletPassphrase || params.userLoginPassword;
1058+
const webauthnInfo = params.webauthnInfo;
10581059
const keysForWalletShares = walletShares.flatMap((walletShare) => {
10591060
// Handle userMultiKeyRotationRequired case - these shares don't have keychains
10601061
if (walletShare.userMultiKeyRotationRequired) {
@@ -1066,13 +1067,22 @@ export class Wallets implements IWallets {
10661067
password: newWalletPassphrase,
10671068
input: walletKeychain.prv,
10681069
});
1069-
return [
1070-
{
1071-
walletShareId: walletShare.id,
1072-
encryptedPrv: encryptedPrv,
1073-
pub: walletKeychain.pub,
1074-
},
1075-
];
1070+
const entry: AcceptShareOptionsRequest = {
1071+
walletShareId: walletShare.id,
1072+
encryptedPrv: encryptedPrv,
1073+
pub: walletKeychain.pub,
1074+
};
1075+
if (webauthnInfo) {
1076+
entry.webauthnInfo = {
1077+
otpDeviceId: webauthnInfo.otpDeviceId,
1078+
prfSalt: webauthnInfo.prfSalt,
1079+
encryptedPrv: this.bitgo.encrypt({
1080+
password: webauthnInfo.passphrase,
1081+
input: walletKeychain.prv,
1082+
}),
1083+
};
1084+
}
1085+
return [entry];
10761086
}
10771087

10781088
// Standard case: shares with keychains
@@ -1092,12 +1102,21 @@ export class Wallets implements IWallets {
10921102
password: newWalletPassphrase,
10931103
input: decryptedSharedWalletPrv,
10941104
});
1095-
return [
1096-
{
1097-
walletShareId: walletShare.id,
1098-
encryptedPrv: newEncryptedPrv,
1099-
},
1100-
];
1105+
const entry: AcceptShareOptionsRequest = {
1106+
walletShareId: walletShare.id,
1107+
encryptedPrv: newEncryptedPrv,
1108+
};
1109+
if (webauthnInfo) {
1110+
entry.webauthnInfo = {
1111+
otpDeviceId: webauthnInfo.otpDeviceId,
1112+
prfSalt: webauthnInfo.prfSalt,
1113+
encryptedPrv: this.bitgo.encrypt({
1114+
password: webauthnInfo.passphrase,
1115+
input: decryptedSharedWalletPrv,
1116+
}),
1117+
};
1118+
}
1119+
return [entry];
11011120
});
11021121

11031122
return this.bulkAcceptShareRequest(keysForWalletShares);

0 commit comments

Comments
 (0)