From 28d7c688d27e70e0c83dd792977473910197b38c Mon Sep 17 00:00:00 2001 From: Weldon Scott Date: Fri, 10 Apr 2026 09:16:53 -0400 Subject: [PATCH] feat: allow webauthn info to be passed in wallet creation Ticket: WP-8495 --- .../src/typedRoutes/api/v2/generateWallet.ts | 8 + .../test/unit/typedRoutes/generateWallet.ts | 98 +++++++++ .../sdk-core/src/bitgo/keychain/iKeychains.ts | 9 + modules/sdk-core/src/bitgo/wallet/iWallets.ts | 12 ++ modules/sdk-core/src/bitgo/wallet/wallets.ts | 15 ++ .../test/unit/bitgo/wallet/walletsWebauthn.ts | 190 ++++++++++++++++++ 6 files changed, 332 insertions(+) create mode 100644 modules/sdk-core/test/unit/bitgo/wallet/walletsWebauthn.ts diff --git a/modules/express/src/typedRoutes/api/v2/generateWallet.ts b/modules/express/src/typedRoutes/api/v2/generateWallet.ts index e4d0f84721..0493aa4cd6 100644 --- a/modules/express/src/typedRoutes/api/v2/generateWallet.ts +++ b/modules/express/src/typedRoutes/api/v2/generateWallet.ts @@ -47,6 +47,14 @@ export const GenerateWalletBody = { commonKeychain: optional(t.string), /** 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. */ evmKeyRingReferenceWalletId: optional(t.string), + /** 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. */ + webauthnInfo: optional( + t.type({ + otpDeviceId: t.string, + prfSalt: t.string, + passphrase: t.string, + }) + ), } as const; export const GenerateWalletResponse200 = t.union([ diff --git a/modules/express/test/unit/typedRoutes/generateWallet.ts b/modules/express/test/unit/typedRoutes/generateWallet.ts index 2b4b9112f5..bf2ff0a16c 100644 --- a/modules/express/test/unit/typedRoutes/generateWallet.ts +++ b/modules/express/test/unit/typedRoutes/generateWallet.ts @@ -345,6 +345,53 @@ describe('Generate Wallet Typed Routes Tests', function () { generateWalletStub.firstCall.args[0].should.have.property('commonKeychain', commonKeychain); }); + it('should successfully generate wallet with webauthnInfo', async function () { + const coin = 'tbtc'; + const label = 'Test Wallet'; + const passphrase = 'mySecurePassphrase123'; + const webauthnInfo = { + otpDeviceId: 'device-abc123', + prfSalt: 'saltXYZ789', + passphrase: 'prf-derived-passphrase', + }; + + const mockWallet = { + id: 'walletWebauthn', + coin, + label, + toJSON: sinon.stub().returns({ id: 'walletWebauthn', coin, label }), + }; + + const walletResponse = { + wallet: mockWallet, + userKeychain: { id: 'userKeyWebauthn', pub: 'xpub...', encryptedPrv: 'encrypted_prv' }, + backupKeychain: { id: 'backupKeyWebauthn', pub: 'xpub...' }, + bitgoKeychain: { id: 'bitgoKeyWebauthn', pub: 'xpub...' }, + }; + + const generateWalletStub = sinon.stub().resolves(walletResponse); + const walletsStub = { generateWallet: generateWalletStub } as any; + const coinStub = { wallets: sinon.stub().returns(walletsStub) } as any; + + sinon.stub(BitGo.prototype, 'coin').returns(coinStub); + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label, + passphrase, + webauthnInfo, + }); + + res.status.should.equal(200); + res.body.should.have.property('wallet'); + + generateWalletStub.should.have.been.calledOnce(); + const calledWith = generateWalletStub.firstCall.args[0]; + calledWith.should.have.property('webauthnInfo'); + calledWith.webauthnInfo.should.have.property('otpDeviceId', webauthnInfo.otpDeviceId); + calledWith.webauthnInfo.should.have.property('prfSalt', webauthnInfo.prfSalt); + calledWith.webauthnInfo.should.have.property('passphrase', webauthnInfo.passphrase); + }); + it('should successfully generate EVM keyring wallet with evmKeyRingReferenceWalletId', async function () { const coin = 'tpolygon'; const label = 'EVM Keyring Child Wallet'; @@ -464,6 +511,57 @@ describe('Generate Wallet Typed Routes Tests', function () { res.body.error.should.match(/backupXpubProvider/); }); + it('should return 400 when webauthnInfo is missing otpDeviceId', async function () { + const coin = 'tbtc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label: 'Test Wallet', + passphrase: 'password', + webauthnInfo: { + prfSalt: 'salt-abc', + passphrase: 'prf-passphrase', + // missing otpDeviceId + }, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + + it('should return 400 when webauthnInfo is missing prfSalt', async function () { + const coin = 'tbtc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label: 'Test Wallet', + passphrase: 'password', + webauthnInfo: { + otpDeviceId: 'device-123', + passphrase: 'prf-passphrase', + // missing prfSalt + }, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + + it('should return 400 when webauthnInfo is missing passphrase', async function () { + const coin = 'tbtc'; + + const res = await agent.post(`/api/v2/${coin}/wallet/generate`).send({ + label: 'Test Wallet', + passphrase: 'password', + webauthnInfo: { + otpDeviceId: 'device-123', + prfSalt: 'salt-abc', + // missing passphrase + }, + }); + + res.status.should.equal(400); + res.body.should.have.property('error'); + }); + it('should return 400 when disableTransactionNotifications is not boolean', async function () { const coin = 'tbtc'; diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index eb978dbfba..774b84682b 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -132,6 +132,15 @@ export interface AddKeychainOptions { // indicates if the key is MPCv2 or not isMPCv2?: boolean; coinSpecific?: { [coinName: string]: unknown }; + /** WebAuthn devices that have an additional encrypted copy of the private key, keyed by PRF-derived passphrases. */ + webauthnDevices?: Array<{ + /** The OTP device ID of the WebAuthn authenticator. */ + otpDeviceId: string; + /** The PRF salt used to derive the encryption passphrase from the authenticator. */ + prfSalt: string; + /** The private key encrypted with the PRF-derived passphrase. */ + encryptedPrv: string; + }>; } export interface ApiKeyShare { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index 02645ebc67..621ef26f34 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -52,6 +52,16 @@ export interface GenerateSMCMpcWalletOptions extends GenerateBaseMpcWalletOption coldDerivationSeed?: string; } +/** WebAuthn PRF-based encryption info for protecting the user private key with a hardware authenticator. */ +export interface GenerateWalletWebauthnInfo { + /** The OTP device ID of the WebAuthn authenticator. */ + otpDeviceId: string; + /** The PRF salt used to derive the passphrase from the authenticator. */ + prfSalt: string; + /** PRF-derived passphrase used to encrypt the user private key. Never sent to the server. */ + passphrase: string; +} + export interface GenerateWalletOptions { label?: string; passphrase?: string; @@ -80,6 +90,8 @@ export interface GenerateWalletOptions { type?: 'hot' | 'cold' | 'custodial' | 'trading'; subType?: 'lightningCustody' | 'lightningSelfCustody'; evmKeyRingReferenceWalletId?: string; + /** 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. */ + webauthnInfo?: GenerateWalletWebauthnInfo; } export const GenerateLightningWalletOptionsCodec = t.intersection( diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 152a200bef..0c191b1670 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -580,6 +580,21 @@ export class Wallets implements IWallets { encryptedPrv: userKeychain.encryptedPrv, originalPasscodeEncryptionCode: params.passcodeEncryptionCode, }; + + // If WebAuthn info is provided, store an additional copy of the private key encrypted + // with the PRF-derived passphrase so the authenticator can later decrypt it. + if (params.webauthnInfo && userKeychain.prv) { + userKeychainParams.webauthnDevices = [ + { + otpDeviceId: params.webauthnInfo.otpDeviceId, + prfSalt: params.webauthnInfo.prfSalt, + encryptedPrv: this.bitgo.encrypt({ + password: params.webauthnInfo.passphrase, + input: userKeychain.prv, + }), + }, + ]; + } } userKeychainParams.reqId = reqId; diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsWebauthn.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsWebauthn.ts new file mode 100644 index 0000000000..5e74b7ff80 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsWebauthn.ts @@ -0,0 +1,190 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import 'should'; +import { Wallets } from '../../../../src/bitgo/wallet/wallets'; + +describe('Wallets - WebAuthn wallet creation', function () { + let wallets: Wallets; + let mockBitGo: any; + let mockBaseCoin: any; + let mockKeychains: any; + + const userPrv = 'xprvSomeUserPrivateKey'; + const userPub = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; + const backupPub = + 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa'; + const bitgoPub = + 'xpub661MyMwAqRbcEYS8w7XLSVeEsBXy79zSzH1J8vCdxAZningWLdN3zgtU6LBpB85b3D2yc8sfvZU521AAwdZafEz7mnzBBsz4wKY5fTtTQBm'; + + beforeEach(function () { + mockKeychains = { + create: sinon.stub().returns({ pub: userPub, prv: userPrv }), + add: sinon.stub().resolves({ id: 'user-key-id', pub: userPub, encryptedPrv: 'encrypted-prv' }), + createBackup: sinon.stub().resolves({ id: 'backup-key-id', pub: backupPub }), + createBitGo: sinon.stub().resolves({ id: 'bitgo-key-id', pub: bitgoPub }), + }; + + const mockWalletData = { id: 'wallet-id', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'] }; + + mockBitGo = { + post: sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockWalletData), + }), + }), + encrypt: sinon + .stub() + .callsFake(({ password, input }: { password: string; input: string }) => `encrypted:${password}:${input}`), + setRequestTracer: sinon.stub(), + }; + + mockBaseCoin = { + isEVM: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(false), + getFamily: sinon.stub().returns('btc'), + getDefaultMultisigType: sinon.stub().returns('onchain'), + keychains: sinon.stub().returns(mockKeychains), + url: sinon.stub().returns('/test/url'), + isValidMofNSetup: sinon.stub().returns(true), + getConfig: sinon.stub().returns({ features: [] }), + supplementGenerateWallet: sinon.stub().callsFake((params: any) => Promise.resolve(params)), + signMessage: sinon.stub().resolves(Buffer.from('aabbcc', 'hex')), + }; + + wallets = new Wallets(mockBitGo, mockBaseCoin); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('generateWallet with webauthnInfo', function () { + it('should add webauthnDevices to keychain params when webauthnInfo is provided', async function () { + const webauthnInfo = { + otpDeviceId: 'device-123', + prfSalt: 'salt-abc', + passphrase: 'prf-derived-passphrase', + }; + + await wallets.generateWallet({ + label: 'Test Wallet', + passphrase: 'wallet-passphrase', + webauthnInfo, + }); + + assert.strictEqual(mockKeychains.add.calledOnce, true); + const addParams = mockKeychains.add.firstCall.args[0]; + addParams.should.have.property('webauthnDevices'); + addParams.webauthnDevices.should.have.length(1); + addParams.webauthnDevices[0].should.have.property('otpDeviceId', webauthnInfo.otpDeviceId); + addParams.webauthnDevices[0].should.have.property('prfSalt', webauthnInfo.prfSalt); + addParams.webauthnDevices[0].should.have.property('encryptedPrv'); + }); + + it('should encrypt user private key with the webauthn passphrase', async function () { + const webauthnPassphrase = 'prf-derived-passphrase'; + + await wallets.generateWallet({ + label: 'Test Wallet', + passphrase: 'wallet-passphrase', + webauthnInfo: { + otpDeviceId: 'device-123', + prfSalt: 'salt-abc', + passphrase: webauthnPassphrase, + }, + }); + + const addParams = mockKeychains.add.firstCall.args[0]; + const expectedEncryptedPrv = `encrypted:${webauthnPassphrase}:${userPrv}`; + addParams.webauthnDevices[0].should.have.property('encryptedPrv', expectedEncryptedPrv); + }); + + it('should also encrypt user private key with wallet passphrase when webauthnInfo is provided', async function () { + const walletPassphrase = 'wallet-passphrase'; + + await wallets.generateWallet({ + label: 'Test Wallet', + passphrase: walletPassphrase, + webauthnInfo: { + otpDeviceId: 'device-123', + prfSalt: 'salt-abc', + passphrase: 'prf-derived-passphrase', + }, + }); + + const addParams = mockKeychains.add.firstCall.args[0]; + const expectedEncryptedPrv = `encrypted:${walletPassphrase}:${userPrv}`; + addParams.should.have.property('encryptedPrv', expectedEncryptedPrv); + }); + + it('should use separate encrypt calls for wallet passphrase and webauthn passphrase', async function () { + const walletPassphrase = 'wallet-passphrase'; + const webauthnPassphrase = 'prf-derived-passphrase'; + + await wallets.generateWallet({ + label: 'Test Wallet', + passphrase: walletPassphrase, + webauthnInfo: { + otpDeviceId: 'device-123', + prfSalt: 'salt-abc', + passphrase: webauthnPassphrase, + }, + }); + + const encryptCalls = mockBitGo.encrypt.getCalls(); + const passwordsUsed = encryptCalls.map((call: sinon.SinonSpyCall) => call.args[0].password); + passwordsUsed.should.containEql(walletPassphrase); + passwordsUsed.should.containEql(webauthnPassphrase); + }); + + it('should not add webauthnDevices when webauthnInfo is not provided', async function () { + await wallets.generateWallet({ + label: 'Test Wallet', + passphrase: 'wallet-passphrase', + }); + + assert.strictEqual(mockKeychains.add.calledOnce, true); + const addParams = mockKeychains.add.firstCall.args[0]; + addParams.should.not.have.property('webauthnDevices'); + }); + + it('should not add webauthnDevices when userKey is explicitly provided (no prv available)', async function () { + // When a user-provided public key is used, there is no private key to encrypt, so webauthnDevices is skipped + await wallets.generateWallet({ + label: 'Test Wallet', + userKey: userPub, + backupXpub: backupPub, + webauthnInfo: { + otpDeviceId: 'device-123', + prfSalt: 'salt-abc', + passphrase: 'prf-derived-passphrase', + }, + }); + + // add is called for both user keychain (pub-only) and backup keychain - neither should have webauthnDevices + const allAddCalls = mockKeychains.add.getCalls(); + assert.ok(allAddCalls.length > 0, 'expected keychains().add to be called at least once'); + for (const call of allAddCalls) { + call.args[0].should.not.have.property('webauthnDevices'); + } + }); + + it('should return wallet with keychains when webauthnInfo is provided', async function () { + const result = await wallets.generateWallet({ + label: 'Test Wallet', + passphrase: 'wallet-passphrase', + webauthnInfo: { + otpDeviceId: 'device-123', + prfSalt: 'salt-abc', + passphrase: 'prf-derived-passphrase', + }, + }); + + result.should.have.property('wallet'); + result.should.have.property('userKeychain'); + result.should.have.property('backupKeychain'); + result.should.have.property('bitgoKeychain'); + }); + }); +});