Skip to content

Commit cdc4442

Browse files
Merge pull request #8365 from BitGo/WP-8314-bulk-accept-webauthn-info
feat(sdk-core): add webauthnInfo support to bulkAcceptShare
2 parents 18057e5 + 808a97e commit cdc4442

3 files changed

Lines changed: 183 additions & 6 deletions

File tree

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

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2504,6 +2504,155 @@ 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 NOT include webauthnInfo when not provided (backward compat)', async () => {
2588+
const fromUserPrv = Math.random();
2589+
const walletPassphrase = 'bitgo1234';
2590+
const shareId = '66a229dbdccdcfb95b44fc2745a60bd4';
2591+
const keychainTest: OptionalKeychainEncryptedKey = {
2592+
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
2593+
};
2594+
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
2595+
if (!userPrv) {
2596+
throw new Error('Unable to decrypt user keychain');
2597+
}
2598+
2599+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
2600+
const path = 'm/999999/1/1';
2601+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
2602+
2603+
const eckey = makeRandomKey();
2604+
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
2605+
const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv });
2606+
2607+
let capturedBody: any;
2608+
nock(bgUrl)
2609+
.get('/api/v2/walletshares')
2610+
.reply(200, {
2611+
incoming: [
2612+
{
2613+
id: shareId,
2614+
isUMSInitiated: true,
2615+
keychain: {
2616+
path: path,
2617+
fromPubKey: eckey.publicKey.toString('hex'),
2618+
encryptedPrv: newEncryptedPrv,
2619+
toPubKey: pubkey,
2620+
pub: pubkey,
2621+
},
2622+
},
2623+
],
2624+
});
2625+
nock(bgUrl)
2626+
.put('/api/v2/walletshares/accept', (body) => {
2627+
capturedBody = body;
2628+
return true;
2629+
})
2630+
.reply(200, {
2631+
acceptedWalletShares: [{ walletShareId: shareId }],
2632+
});
2633+
2634+
const myEcdhKeychain = await bitgo.keychains().create();
2635+
sinon.stub(bitgo, 'getECDHKeychain').resolves({
2636+
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2637+
});
2638+
const prvKey = bitgo.decrypt({
2639+
password: walletPassphrase,
2640+
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
2641+
});
2642+
sinon.stub(bitgo, 'decrypt').returns(prvKey);
2643+
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');
2644+
2645+
await wallets.bulkAcceptShare({
2646+
walletShareIds: [shareId],
2647+
userLoginPassword: walletPassphrase,
2648+
});
2649+
2650+
const sentEntries = capturedBody.keysForWalletShares;
2651+
sentEntries.should.have.length(1);
2652+
sentEntries[0].should.have.property('encryptedPrv');
2653+
sentEntries[0].should.not.have.property('webauthnInfo');
2654+
});
2655+
25072656
it('should handle 413 payload too large error with smart retry', async () => {
25082657
const walletPassphrase = 'bitgo1234';
25092658
const fromUserPrv = Math.random();

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

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

137+
export interface AcceptShareWebauthnInfo {
138+
otpDeviceId: string;
139+
prfSalt: string;
140+
passphrase: string;
141+
}
142+
137143
export interface BulkAcceptShareOptions {
138144
walletShareIds: string[];
139145
userLoginPassword: string;
140146
newWalletPassphrase?: string;
147+
webauthnInfo?: AcceptShareWebauthnInfo;
141148
}
142149

143150
export interface AcceptShareOptionsRequest {
@@ -148,6 +155,17 @@ export interface AcceptShareOptionsRequest {
148155
* Required for userMultiKeyRotationRequired shares.
149156
*/
150157
pub?: string;
158+
/**
159+
* Optional WebAuthn PRF-based encryption info.
160+
* When provided, the wallet private key is additionally encrypted with the
161+
* PRF-derived passphrase so the server can store a WebAuthn-protected copy.
162+
* The passphrase itself is never sent to the server.
163+
*/
164+
webauthnInfo?: {
165+
otpDeviceId: string;
166+
prfSalt: string;
167+
encryptedPrv: string;
168+
};
151169
}
152170

153171
export interface BulkUpdateWalletShareOptions {

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

Lines changed: 16 additions & 6 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) {
@@ -1092,12 +1093,21 @@ export class Wallets implements IWallets {
10921093
password: newWalletPassphrase,
10931094
input: decryptedSharedWalletPrv,
10941095
});
1095-
return [
1096-
{
1097-
walletShareId: walletShare.id,
1098-
encryptedPrv: newEncryptedPrv,
1099-
},
1100-
];
1096+
const entry: AcceptShareOptionsRequest = {
1097+
walletShareId: walletShare.id,
1098+
encryptedPrv: newEncryptedPrv,
1099+
};
1100+
if (webauthnInfo) {
1101+
entry.webauthnInfo = {
1102+
otpDeviceId: webauthnInfo.otpDeviceId,
1103+
prfSalt: webauthnInfo.prfSalt,
1104+
encryptedPrv: this.bitgo.encrypt({
1105+
password: webauthnInfo.passphrase,
1106+
input: decryptedSharedWalletPrv,
1107+
}),
1108+
};
1109+
}
1110+
return [entry];
11011111
});
11021112

11031113
return this.bulkAcceptShareRequest(keysForWalletShares);

0 commit comments

Comments
 (0)