Skip to content

Commit 28d7c68

Browse files
committed
feat: allow webauthn info to be passed in wallet creation
Ticket: WP-8495
1 parent f5e3234 commit 28d7c68

File tree

6 files changed

+332
-0
lines changed

6 files changed

+332
-0
lines changed

modules/express/src/typedRoutes/api/v2/generateWallet.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ export const GenerateWalletBody = {
4747
commonKeychain: optional(t.string),
4848
/** Reference wallet ID for creating EVM keyring child wallets. When provided, the new wallet inherits keys and properties from the reference wallet, enabling unified addresses across EVM chains. */
4949
evmKeyRingReferenceWalletId: optional(t.string),
50+
/** Optional WebAuthn PRF-based encryption info. When provided, the user private key is additionally encrypted with the PRF-derived passphrase so the server can store a WebAuthn-protected copy. The passphrase itself is never sent to the server. */
51+
webauthnInfo: optional(
52+
t.type({
53+
otpDeviceId: t.string,
54+
prfSalt: t.string,
55+
passphrase: t.string,
56+
})
57+
),
5058
} as const;
5159

5260
export const GenerateWalletResponse200 = t.union([

modules/express/test/unit/typedRoutes/generateWallet.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,53 @@ describe('Generate Wallet Typed Routes Tests', function () {
345345
generateWalletStub.firstCall.args[0].should.have.property('commonKeychain', commonKeychain);
346346
});
347347

348+
it('should successfully generate wallet with webauthnInfo', async function () {
349+
const coin = 'tbtc';
350+
const label = 'Test Wallet';
351+
const passphrase = 'mySecurePassphrase123';
352+
const webauthnInfo = {
353+
otpDeviceId: 'device-abc123',
354+
prfSalt: 'saltXYZ789',
355+
passphrase: 'prf-derived-passphrase',
356+
};
357+
358+
const mockWallet = {
359+
id: 'walletWebauthn',
360+
coin,
361+
label,
362+
toJSON: sinon.stub().returns({ id: 'walletWebauthn', coin, label }),
363+
};
364+
365+
const walletResponse = {
366+
wallet: mockWallet,
367+
userKeychain: { id: 'userKeyWebauthn', pub: 'xpub...', encryptedPrv: 'encrypted_prv' },
368+
backupKeychain: { id: 'backupKeyWebauthn', pub: 'xpub...' },
369+
bitgoKeychain: { id: 'bitgoKeyWebauthn', pub: 'xpub...' },
370+
};
371+
372+
const generateWalletStub = sinon.stub().resolves(walletResponse);
373+
const walletsStub = { generateWallet: generateWalletStub } as any;
374+
const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any;
375+
376+
sinon.stub(BitGo.prototype, 'coin').returns(coinStub);
377+
378+
const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({
379+
label,
380+
passphrase,
381+
webauthnInfo,
382+
});
383+
384+
res.status.should.equal(200);
385+
res.body.should.have.property('wallet');
386+
387+
generateWalletStub.should.have.been.calledOnce();
388+
const calledWith = generateWalletStub.firstCall.args[0];
389+
calledWith.should.have.property('webauthnInfo');
390+
calledWith.webauthnInfo.should.have.property('otpDeviceId', webauthnInfo.otpDeviceId);
391+
calledWith.webauthnInfo.should.have.property('prfSalt', webauthnInfo.prfSalt);
392+
calledWith.webauthnInfo.should.have.property('passphrase', webauthnInfo.passphrase);
393+
});
394+
348395
it('should successfully generate EVM keyring wallet with evmKeyRingReferenceWalletId', async function () {
349396
const coin = 'tpolygon';
350397
const label = 'EVM Keyring Child Wallet';
@@ -464,6 +511,57 @@ describe('Generate Wallet Typed Routes Tests', function () {
464511
res.body.error.should.match(/backupXpubProvider/);
465512
});
466513

514+
it('should return 400 when webauthnInfo is missing otpDeviceId', async function () {
515+
const coin = 'tbtc';
516+
517+
const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({
518+
label: 'Test Wallet',
519+
passphrase: 'password',
520+
webauthnInfo: {
521+
prfSalt: 'salt-abc',
522+
passphrase: 'prf-passphrase',
523+
// missing otpDeviceId
524+
},
525+
});
526+
527+
res.status.should.equal(400);
528+
res.body.should.have.property('error');
529+
});
530+
531+
it('should return 400 when webauthnInfo is missing prfSalt', async function () {
532+
const coin = 'tbtc';
533+
534+
const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({
535+
label: 'Test Wallet',
536+
passphrase: 'password',
537+
webauthnInfo: {
538+
otpDeviceId: 'device-123',
539+
passphrase: 'prf-passphrase',
540+
// missing prfSalt
541+
},
542+
});
543+
544+
res.status.should.equal(400);
545+
res.body.should.have.property('error');
546+
});
547+
548+
it('should return 400 when webauthnInfo is missing passphrase', async function () {
549+
const coin = 'tbtc';
550+
551+
const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({
552+
label: 'Test Wallet',
553+
passphrase: 'password',
554+
webauthnInfo: {
555+
otpDeviceId: 'device-123',
556+
prfSalt: 'salt-abc',
557+
// missing passphrase
558+
},
559+
});
560+
561+
res.status.should.equal(400);
562+
res.body.should.have.property('error');
563+
});
564+
467565
it('should return 400 when disableTransactionNotifications is not boolean', async function () {
468566
const coin = 'tbtc';
469567

modules/sdk-core/src/bitgo/keychain/iKeychains.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,15 @@ export interface AddKeychainOptions {
132132
// indicates if the key is MPCv2 or not
133133
isMPCv2?: boolean;
134134
coinSpecific?: { [coinName: string]: unknown };
135+
/** WebAuthn devices that have an additional encrypted copy of the private key, keyed by PRF-derived passphrases. */
136+
webauthnDevices?: Array<{
137+
/** The OTP device ID of the WebAuthn authenticator. */
138+
otpDeviceId: string;
139+
/** The PRF salt used to derive the encryption passphrase from the authenticator. */
140+
prfSalt: string;
141+
/** The private key encrypted with the PRF-derived passphrase. */
142+
encryptedPrv: string;
143+
}>;
135144
}
136145

137146
export interface ApiKeyShare {

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ export interface GenerateSMCMpcWalletOptions extends GenerateBaseMpcWalletOption
5252
coldDerivationSeed?: string;
5353
}
5454

55+
/** WebAuthn PRF-based encryption info for protecting the user private key with a hardware authenticator. */
56+
export interface GenerateWalletWebauthnInfo {
57+
/** The OTP device ID of the WebAuthn authenticator. */
58+
otpDeviceId: string;
59+
/** The PRF salt used to derive the passphrase from the authenticator. */
60+
prfSalt: string;
61+
/** PRF-derived passphrase used to encrypt the user private key. Never sent to the server. */
62+
passphrase: string;
63+
}
64+
5565
export interface GenerateWalletOptions {
5666
label?: string;
5767
passphrase?: string;
@@ -80,6 +90,8 @@ export interface GenerateWalletOptions {
8090
type?: 'hot' | 'cold' | 'custodial' | 'trading';
8191
subType?: 'lightningCustody' | 'lightningSelfCustody';
8292
evmKeyRingReferenceWalletId?: string;
93+
/** Optional WebAuthn PRF-based encryption info. When provided, the user private key is additionally encrypted with the PRF-derived passphrase so the server can store a WebAuthn-protected copy. */
94+
webauthnInfo?: GenerateWalletWebauthnInfo;
8395
}
8496

8597
export const GenerateLightningWalletOptionsCodec = t.intersection(

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,21 @@ export class Wallets implements IWallets {
580580
encryptedPrv: userKeychain.encryptedPrv,
581581
originalPasscodeEncryptionCode: params.passcodeEncryptionCode,
582582
};
583+
584+
// If WebAuthn info is provided, store an additional copy of the private key encrypted
585+
// with the PRF-derived passphrase so the authenticator can later decrypt it.
586+
if (params.webauthnInfo && userKeychain.prv) {
587+
userKeychainParams.webauthnDevices = [
588+
{
589+
otpDeviceId: params.webauthnInfo.otpDeviceId,
590+
prfSalt: params.webauthnInfo.prfSalt,
591+
encryptedPrv: this.bitgo.encrypt({
592+
password: params.webauthnInfo.passphrase,
593+
input: userKeychain.prv,
594+
}),
595+
},
596+
];
597+
}
583598
}
584599

585600
userKeychainParams.reqId = reqId;
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import 'should';
4+
import { Wallets } from '../../../../src/bitgo/wallet/wallets';
5+
6+
describe('Wallets - WebAuthn wallet creation', function () {
7+
let wallets: Wallets;
8+
let mockBitGo: any;
9+
let mockBaseCoin: any;
10+
let mockKeychains: any;
11+
12+
const userPrv = 'xprvSomeUserPrivateKey';
13+
const userPub =
14+
'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8';
15+
const backupPub =
16+
'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa';
17+
const bitgoPub =
18+
'xpub661MyMwAqRbcEYS8w7XLSVeEsBXy79zSzH1J8vCdxAZningWLdN3zgtU6LBpB85b3D2yc8sfvZU521AAwdZafEz7mnzBBsz4wKY5fTtTQBm';
19+
20+
beforeEach(function () {
21+
mockKeychains = {
22+
create: sinon.stub().returns({ pub: userPub, prv: userPrv }),
23+
add: sinon.stub().resolves({ id: 'user-key-id', pub: userPub, encryptedPrv: 'encrypted-prv' }),
24+
createBackup: sinon.stub().resolves({ id: 'backup-key-id', pub: backupPub }),
25+
createBitGo: sinon.stub().resolves({ id: 'bitgo-key-id', pub: bitgoPub }),
26+
};
27+
28+
const mockWalletData = { id: 'wallet-id', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'] };
29+
30+
mockBitGo = {
31+
post: sinon.stub().returns({
32+
send: sinon.stub().returns({
33+
result: sinon.stub().resolves(mockWalletData),
34+
}),
35+
}),
36+
encrypt: sinon
37+
.stub()
38+
.callsFake(({ password, input }: { password: string; input: string }) => `encrypted:${password}:${input}`),
39+
setRequestTracer: sinon.stub(),
40+
};
41+
42+
mockBaseCoin = {
43+
isEVM: sinon.stub().returns(false),
44+
supportsTss: sinon.stub().returns(false),
45+
getFamily: sinon.stub().returns('btc'),
46+
getDefaultMultisigType: sinon.stub().returns('onchain'),
47+
keychains: sinon.stub().returns(mockKeychains),
48+
url: sinon.stub().returns('/test/url'),
49+
isValidMofNSetup: sinon.stub().returns(true),
50+
getConfig: sinon.stub().returns({ features: [] }),
51+
supplementGenerateWallet: sinon.stub().callsFake((params: any) => Promise.resolve(params)),
52+
signMessage: sinon.stub().resolves(Buffer.from('aabbcc', 'hex')),
53+
};
54+
55+
wallets = new Wallets(mockBitGo, mockBaseCoin);
56+
});
57+
58+
afterEach(function () {
59+
sinon.restore();
60+
});
61+
62+
describe('generateWallet with webauthnInfo', function () {
63+
it('should add webauthnDevices to keychain params when webauthnInfo is provided', async function () {
64+
const webauthnInfo = {
65+
otpDeviceId: 'device-123',
66+
prfSalt: 'salt-abc',
67+
passphrase: 'prf-derived-passphrase',
68+
};
69+
70+
await wallets.generateWallet({
71+
label: 'Test Wallet',
72+
passphrase: 'wallet-passphrase',
73+
webauthnInfo,
74+
});
75+
76+
assert.strictEqual(mockKeychains.add.calledOnce, true);
77+
const addParams = mockKeychains.add.firstCall.args[0];
78+
addParams.should.have.property('webauthnDevices');
79+
addParams.webauthnDevices.should.have.length(1);
80+
addParams.webauthnDevices[0].should.have.property('otpDeviceId', webauthnInfo.otpDeviceId);
81+
addParams.webauthnDevices[0].should.have.property('prfSalt', webauthnInfo.prfSalt);
82+
addParams.webauthnDevices[0].should.have.property('encryptedPrv');
83+
});
84+
85+
it('should encrypt user private key with the webauthn passphrase', async function () {
86+
const webauthnPassphrase = 'prf-derived-passphrase';
87+
88+
await wallets.generateWallet({
89+
label: 'Test Wallet',
90+
passphrase: 'wallet-passphrase',
91+
webauthnInfo: {
92+
otpDeviceId: 'device-123',
93+
prfSalt: 'salt-abc',
94+
passphrase: webauthnPassphrase,
95+
},
96+
});
97+
98+
const addParams = mockKeychains.add.firstCall.args[0];
99+
const expectedEncryptedPrv = `encrypted:${webauthnPassphrase}:${userPrv}`;
100+
addParams.webauthnDevices[0].should.have.property('encryptedPrv', expectedEncryptedPrv);
101+
});
102+
103+
it('should also encrypt user private key with wallet passphrase when webauthnInfo is provided', async function () {
104+
const walletPassphrase = 'wallet-passphrase';
105+
106+
await wallets.generateWallet({
107+
label: 'Test Wallet',
108+
passphrase: walletPassphrase,
109+
webauthnInfo: {
110+
otpDeviceId: 'device-123',
111+
prfSalt: 'salt-abc',
112+
passphrase: 'prf-derived-passphrase',
113+
},
114+
});
115+
116+
const addParams = mockKeychains.add.firstCall.args[0];
117+
const expectedEncryptedPrv = `encrypted:${walletPassphrase}:${userPrv}`;
118+
addParams.should.have.property('encryptedPrv', expectedEncryptedPrv);
119+
});
120+
121+
it('should use separate encrypt calls for wallet passphrase and webauthn passphrase', async function () {
122+
const walletPassphrase = 'wallet-passphrase';
123+
const webauthnPassphrase = 'prf-derived-passphrase';
124+
125+
await wallets.generateWallet({
126+
label: 'Test Wallet',
127+
passphrase: walletPassphrase,
128+
webauthnInfo: {
129+
otpDeviceId: 'device-123',
130+
prfSalt: 'salt-abc',
131+
passphrase: webauthnPassphrase,
132+
},
133+
});
134+
135+
const encryptCalls = mockBitGo.encrypt.getCalls();
136+
const passwordsUsed = encryptCalls.map((call: sinon.SinonSpyCall) => call.args[0].password);
137+
passwordsUsed.should.containEql(walletPassphrase);
138+
passwordsUsed.should.containEql(webauthnPassphrase);
139+
});
140+
141+
it('should not add webauthnDevices when webauthnInfo is not provided', async function () {
142+
await wallets.generateWallet({
143+
label: 'Test Wallet',
144+
passphrase: 'wallet-passphrase',
145+
});
146+
147+
assert.strictEqual(mockKeychains.add.calledOnce, true);
148+
const addParams = mockKeychains.add.firstCall.args[0];
149+
addParams.should.not.have.property('webauthnDevices');
150+
});
151+
152+
it('should not add webauthnDevices when userKey is explicitly provided (no prv available)', async function () {
153+
// When a user-provided public key is used, there is no private key to encrypt, so webauthnDevices is skipped
154+
await wallets.generateWallet({
155+
label: 'Test Wallet',
156+
userKey: userPub,
157+
backupXpub: backupPub,
158+
webauthnInfo: {
159+
otpDeviceId: 'device-123',
160+
prfSalt: 'salt-abc',
161+
passphrase: 'prf-derived-passphrase',
162+
},
163+
});
164+
165+
// add is called for both user keychain (pub-only) and backup keychain - neither should have webauthnDevices
166+
const allAddCalls = mockKeychains.add.getCalls();
167+
assert.ok(allAddCalls.length > 0, 'expected keychains().add to be called at least once');
168+
for (const call of allAddCalls) {
169+
call.args[0].should.not.have.property('webauthnDevices');
170+
}
171+
});
172+
173+
it('should return wallet with keychains when webauthnInfo is provided', async function () {
174+
const result = await wallets.generateWallet({
175+
label: 'Test Wallet',
176+
passphrase: 'wallet-passphrase',
177+
webauthnInfo: {
178+
otpDeviceId: 'device-123',
179+
prfSalt: 'salt-abc',
180+
passphrase: 'prf-derived-passphrase',
181+
},
182+
});
183+
184+
result.should.have.property('wallet');
185+
result.should.have.property('userKeychain');
186+
result.should.have.property('backupKeychain');
187+
result.should.have.property('bitgoKeychain');
188+
});
189+
});
190+
});

0 commit comments

Comments
 (0)