Skip to content

Commit b26eaff

Browse files
feat(sdk-core): add webauthnInfo support to singular acceptShare
When callers provide webauthnInfo, acceptShare now computes a PRF-encrypted copy of the wallet private key and includes it in the updateShare payload. This enables the server to store a WebAuthn-protected copy alongside the password-encrypted copy, allowing passwordless wallet access via passkey. Ticket: WP-8310
1 parent 5b82c98 commit b26eaff

File tree

3 files changed

+313
-0
lines changed

3 files changed

+313
-0
lines changed

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

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2313,6 +2313,284 @@ describe('V2 Wallets:', function () {
23132313
});
23142314
});
23152315

2316+
describe('acceptShare with webauthnInfo', () => {
2317+
const sandbox = sinon.createSandbox();
2318+
2319+
afterEach(function () {
2320+
sandbox.verifyAndRestore();
2321+
});
2322+
2323+
it('should include webauthnInfo in updateShare when provided (ECDH branch)', async function () {
2324+
const shareId = 'test_webauthn_ecdh_1';
2325+
const userPassword = 'test_password_123';
2326+
const webauthnPassphrase = 'prf-derived-secret';
2327+
2328+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
2329+
const path = 'm/999999/1/1';
2330+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
2331+
2332+
const eckey = makeRandomKey();
2333+
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
2334+
const walletPrv = 'wallet-private-key-for-test';
2335+
const encryptedPrvFromShare = bitgo.encrypt({ password: secret, input: walletPrv });
2336+
2337+
const myEcdhKeychain = await bitgo.keychains().create();
2338+
2339+
const walletShareNock = nock(bgUrl)
2340+
.get(`/api/v2/tbtc/walletshare/${shareId}`)
2341+
.reply(200, {
2342+
id: shareId,
2343+
keychain: {
2344+
path: path,
2345+
fromPubKey: eckey.publicKey.toString('hex'),
2346+
encryptedPrv: encryptedPrvFromShare,
2347+
toPubKey: pubkey,
2348+
pub: pubkey,
2349+
},
2350+
});
2351+
2352+
sandbox.stub(bitgo, 'getECDHKeychain').resolves({
2353+
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: userPassword }),
2354+
});
2355+
2356+
const prvKey = bitgo.decrypt({
2357+
password: userPassword,
2358+
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: userPassword }),
2359+
});
2360+
sandbox.stub(bitgo, 'decrypt').returns(prvKey);
2361+
sandbox.stub(moduleBitgo, 'getSharedSecret').returns(Buffer.from(secret, 'hex'));
2362+
2363+
let capturedBody: any;
2364+
const acceptShareNock = nock(bgUrl)
2365+
.post(`/api/v2/tbtc/walletshare/${shareId}`, (body: any) => {
2366+
capturedBody = body;
2367+
return true;
2368+
})
2369+
.reply(200, { changed: true, state: 'accepted' });
2370+
2371+
await wallets.acceptShare({
2372+
walletShareId: shareId,
2373+
userPassword,
2374+
webauthnInfo: {
2375+
otpDeviceId: 'device-001',
2376+
prfSalt: 'salt-abc',
2377+
passphrase: webauthnPassphrase,
2378+
},
2379+
});
2380+
2381+
should.exist(capturedBody.encryptedPrv);
2382+
should.exist(capturedBody.webauthnInfo);
2383+
should.equal(capturedBody.webauthnInfo.otpDeviceId, 'device-001');
2384+
should.equal(capturedBody.webauthnInfo.prfSalt, 'salt-abc');
2385+
should.exist(capturedBody.webauthnInfo.encryptedPrv);
2386+
should.not.exist(capturedBody.webauthnInfo.passphrase);
2387+
2388+
walletShareNock.done();
2389+
acceptShareNock.done();
2390+
});
2391+
2392+
it('should NOT include webauthnInfo when not provided (ECDH backward compat)', async function () {
2393+
const shareId = 'test_webauthn_ecdh_2';
2394+
const userPassword = 'test_password_123';
2395+
2396+
const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
2397+
const path = 'm/999999/1/1';
2398+
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');
2399+
2400+
const eckey = makeRandomKey();
2401+
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
2402+
const walletPrv = 'wallet-private-key-for-test';
2403+
const encryptedPrvFromShare = bitgo.encrypt({ password: secret, input: walletPrv });
2404+
2405+
const myEcdhKeychain = await bitgo.keychains().create();
2406+
2407+
const walletShareNock = nock(bgUrl)
2408+
.get(`/api/v2/tbtc/walletshare/${shareId}`)
2409+
.reply(200, {
2410+
id: shareId,
2411+
keychain: {
2412+
path: path,
2413+
fromPubKey: eckey.publicKey.toString('hex'),
2414+
encryptedPrv: encryptedPrvFromShare,
2415+
toPubKey: pubkey,
2416+
pub: pubkey,
2417+
},
2418+
});
2419+
2420+
sandbox.stub(bitgo, 'getECDHKeychain').resolves({
2421+
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: userPassword }),
2422+
});
2423+
2424+
const prvKey = bitgo.decrypt({
2425+
password: userPassword,
2426+
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: userPassword }),
2427+
});
2428+
sandbox.stub(bitgo, 'decrypt').returns(prvKey);
2429+
sandbox.stub(moduleBitgo, 'getSharedSecret').returns(Buffer.from(secret, 'hex'));
2430+
2431+
let capturedBody: any;
2432+
const acceptShareNock = nock(bgUrl)
2433+
.post(`/api/v2/tbtc/walletshare/${shareId}`, (body: any) => {
2434+
capturedBody = body;
2435+
return true;
2436+
})
2437+
.reply(200, { changed: true, state: 'accepted' });
2438+
2439+
await wallets.acceptShare({
2440+
walletShareId: shareId,
2441+
userPassword,
2442+
});
2443+
2444+
should.exist(capturedBody.encryptedPrv);
2445+
should.not.exist(capturedBody.webauthnInfo);
2446+
2447+
walletShareNock.done();
2448+
acceptShareNock.done();
2449+
});
2450+
2451+
it('should include webauthnInfo in updateShare when provided (userMultiKeyRotationRequired branch)', async function () {
2452+
const shareId = 'test_webauthn_multi_1';
2453+
const userPassword = 'test_password_123';
2454+
const webauthnPassphrase = 'prf-derived-secret';
2455+
const walletId = 'test_wallet_123';
2456+
2457+
const walletShareNock = nock(bgUrl)
2458+
.get(`/api/v2/ofc/walletshare/${shareId}`)
2459+
.reply(200, {
2460+
userMultiKeyRotationRequired: true,
2461+
permissions: ['admin', 'spend', 'view'],
2462+
wallet: walletId,
2463+
});
2464+
2465+
const testKeychain = bitgo.coin('ofc').keychains().create();
2466+
const keychain = {
2467+
prv: testKeychain.prv,
2468+
pub: testKeychain.pub,
2469+
};
2470+
2471+
const keychainsInstance = ofcWallets.baseCoin.keychains();
2472+
sandbox.stub(keychainsInstance, 'create').returns(keychain);
2473+
2474+
let capturedBody: any;
2475+
const acceptShareNock = nock(bgUrl)
2476+
.post(`/api/v2/ofc/walletshare/${shareId}`, (body: any) => {
2477+
capturedBody = body;
2478+
return true;
2479+
})
2480+
.reply(200, { changed: true, state: 'accepted' });
2481+
2482+
await ofcWallets.acceptShare({
2483+
walletShareId: shareId,
2484+
userPassword,
2485+
webauthnInfo: {
2486+
otpDeviceId: 'device-002',
2487+
prfSalt: 'salt-def',
2488+
passphrase: webauthnPassphrase,
2489+
},
2490+
});
2491+
2492+
should.exist(capturedBody.encryptedPrv);
2493+
should.exist(capturedBody.pub);
2494+
should.exist(capturedBody.webauthnInfo);
2495+
should.equal(capturedBody.webauthnInfo.otpDeviceId, 'device-002');
2496+
should.equal(capturedBody.webauthnInfo.prfSalt, 'salt-def');
2497+
should.exist(capturedBody.webauthnInfo.encryptedPrv);
2498+
should.not.exist(capturedBody.webauthnInfo.passphrase);
2499+
2500+
walletShareNock.done();
2501+
acceptShareNock.done();
2502+
});
2503+
2504+
it('should NOT include webauthnInfo when not provided (userMultiKeyRotationRequired backward compat)', async function () {
2505+
const shareId = 'test_webauthn_multi_2';
2506+
const userPassword = 'test_password_123';
2507+
const walletId = 'test_wallet_123';
2508+
2509+
const walletShareNock = nock(bgUrl)
2510+
.get(`/api/v2/ofc/walletshare/${shareId}`)
2511+
.reply(200, {
2512+
userMultiKeyRotationRequired: true,
2513+
permissions: ['admin', 'spend', 'view'],
2514+
wallet: walletId,
2515+
});
2516+
2517+
const testKeychain = bitgo.coin('ofc').keychains().create();
2518+
const keychain = {
2519+
prv: testKeychain.prv,
2520+
pub: testKeychain.pub,
2521+
};
2522+
2523+
const keychainsInstance = ofcWallets.baseCoin.keychains();
2524+
sandbox.stub(keychainsInstance, 'create').returns(keychain);
2525+
2526+
const encryptedPrv = bitgo.encrypt({ input: keychain.prv, password: userPassword });
2527+
sandbox.stub(bitgo, 'encrypt').returns(encryptedPrv);
2528+
2529+
let capturedBody: any;
2530+
const acceptShareNock = nock(bgUrl)
2531+
.post(`/api/v2/ofc/walletshare/${shareId}`, (body: any) => {
2532+
capturedBody = body;
2533+
return true;
2534+
})
2535+
.reply(200, { changed: true, state: 'accepted' });
2536+
2537+
await ofcWallets.acceptShare({
2538+
walletShareId: shareId,
2539+
userPassword,
2540+
});
2541+
2542+
should.exist(capturedBody.encryptedPrv);
2543+
should.exist(capturedBody.pub);
2544+
should.not.exist(capturedBody.webauthnInfo);
2545+
2546+
walletShareNock.done();
2547+
acceptShareNock.done();
2548+
});
2549+
2550+
it('should NOT include webauthnInfo for keychainOverrideRequired case even when provided', async function () {
2551+
const shareId = 'test_webauthn_override_1';
2552+
const userPassword = 'test_password_123';
2553+
const keychainId = 'test_keychain_id';
2554+
2555+
const keyChainNock = nock(bgUrl)
2556+
.post('/api/v2/ofc/key', _.conforms({ pub: (p) => p.startsWith('xpub') }))
2557+
.reply(200, (uri, requestBody) => {
2558+
return { id: keychainId, encryptedPrv: requestBody['encryptedPrv'], pub: requestBody['pub'] };
2559+
});
2560+
2561+
const walletShareNock = nock(bgUrl)
2562+
.get(`/api/v2/ofc/walletshare/${shareId}`)
2563+
.reply(200, {
2564+
keychainOverrideRequired: true,
2565+
permissions: ['admin', 'spend', 'view'],
2566+
});
2567+
2568+
let capturedBody: any;
2569+
const acceptShareNock = nock(bgUrl)
2570+
.post(`/api/v2/ofc/walletshare/${shareId}`, (body: any) => {
2571+
capturedBody = body;
2572+
return true;
2573+
})
2574+
.reply(200, { changed: false });
2575+
2576+
await ofcWallets.acceptShare({
2577+
walletShareId: shareId,
2578+
userPassword,
2579+
webauthnInfo: {
2580+
otpDeviceId: 'device-003',
2581+
prfSalt: 'salt-ghi',
2582+
passphrase: 'prf-derived-secret',
2583+
},
2584+
});
2585+
2586+
should.not.exist(capturedBody.webauthnInfo);
2587+
2588+
walletShareNock.done();
2589+
keyChainNock.done();
2590+
acceptShareNock.done();
2591+
});
2592+
});
2593+
23162594
it('should share a wallet to viewer', async function () {
23172595
const shareId = '12311';
23182596

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,25 @@ export interface UpdateShareOptions {
125125
signature?: string;
126126
payload?: string;
127127
pub?: string;
128+
/**
129+
* Optional WebAuthn PRF-based encryption info.
130+
* When provided, the wallet private key is additionally encrypted with the
131+
* PRF-derived passphrase so the server can store a WebAuthn-protected copy.
132+
* The passphrase itself is never sent to the server.
133+
*/
134+
webauthnInfo?: {
135+
otpDeviceId: string;
136+
prfSalt: string;
137+
encryptedPrv: string;
138+
};
128139
}
129140

130141
export interface AcceptShareOptions {
131142
overrideEncryptedPrv?: string;
132143
walletShareId?: string;
133144
userPassword?: string;
134145
newWalletPassphrase?: string;
146+
webauthnInfo?: AcceptShareWebauthnInfo;
135147
}
136148

137149
export interface AcceptShareWebauthnInfo {

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,17 @@ export class Wallets implements IWallets {
903903
pub: walletKeychain.pub,
904904
};
905905

906+
if (params.webauthnInfo) {
907+
updateParams.webauthnInfo = {
908+
otpDeviceId: params.webauthnInfo.otpDeviceId,
909+
prfSalt: params.webauthnInfo.prfSalt,
910+
encryptedPrv: this.bitgo.encrypt({
911+
password: params.webauthnInfo.passphrase,
912+
input: walletKeychain.prv,
913+
}),
914+
};
915+
}
916+
906917
// Note: Unlike keychainOverrideRequired, we do NOT reshare the wallet with spenders
907918
// This is a key difference - multi-key-user-key wallets don't require reshare
908919
return this.updateShare(updateParams);
@@ -1004,6 +1015,18 @@ export class Wallets implements IWallets {
10041015
if (encryptedPrv) {
10051016
updateParams.encryptedPrv = encryptedPrv;
10061017
}
1018+
1019+
if (params.webauthnInfo) {
1020+
updateParams.webauthnInfo = {
1021+
otpDeviceId: params.webauthnInfo.otpDeviceId,
1022+
prfSalt: params.webauthnInfo.prfSalt,
1023+
encryptedPrv: this.bitgo.encrypt({
1024+
password: params.webauthnInfo.passphrase,
1025+
input: decryptedSharedWalletPrv,
1026+
}),
1027+
};
1028+
}
1029+
10071030
return this.updateShare(updateParams);
10081031
}
10091032

0 commit comments

Comments
 (0)