Skip to content

Commit 6283604

Browse files
committed
feat: allow webauthn info to be passed in wallet creation
Ticket: WP-8495
1 parent 190b1d0 commit 6283604

File tree

7 files changed

+334
-6
lines changed

7 files changed

+334
-6
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { BitGoKeyFromOvcShares, OvcToBitGoJSON } from './ovcJsonCodec';
66

77
export type KeyType = 'tss' | 'independent' | 'blsdkg';
88

9+
export interface WebauthnInfo {
10+
prfSalt: string;
11+
otpDeviceId: string;
12+
encryptedPrv: string;
13+
}
14+
915
export type SourceType = 'bitgo' | 'backup' | 'user' | 'cold';
1016

1117
export type WebauthnFmt = 'none' | 'packed' | 'fido-u2f';
@@ -132,6 +138,8 @@ export interface AddKeychainOptions {
132138
// indicates if the key is MPCv2 or not
133139
isMPCv2?: boolean;
134140
coinSpecific?: { [coinName: string]: unknown };
141+
/** WebAuthn devices that have an additional encrypted copy of the private key, keyed by PRF-derived passphrases. */
142+
webauthnDevices?: WebauthnInfo[];
135143
}
136144

137145
export interface ApiKeyShare {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export class Keychains implements IKeychains {
244244
isDistributedCustody: params.isDistributedCustody,
245245
isMPCv2: params.isMPCv2,
246246
coinSpecific: params.coinSpecific,
247+
webauthnDevices: params.webauthnDevices,
247248
})
248249
.result();
249250
}

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as t from 'io-ts';
22

33
import { IRequestTracer } from '../../api';
44
import { KeychainsTriplet, LightningKeychainsTriplet } from '../baseCoin';
5-
import { Keychain } from '../keychain';
5+
import { Keychain, WebauthnInfo } from '../keychain';
66
import { IWallet, PaginationOptions, WalletShare } from './iWallet';
77
import { Wallet } from './wallet';
88

@@ -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(
@@ -161,11 +173,7 @@ export interface AcceptShareOptionsRequest {
161173
* PRF-derived passphrase so the server can store a WebAuthn-protected copy.
162174
* The passphrase itself is never sent to the server.
163175
*/
164-
webauthnInfo?: {
165-
otpDeviceId: string;
166-
prfSalt: string;
167-
encryptedPrv: string;
168-
};
176+
webauthnInfo?: WebauthnInfo;
169177
}
170178

171179
export interface BulkUpdateWalletShareOptions {

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;

0 commit comments

Comments
 (0)