Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions modules/express/src/typedRoutes/api/v2/generateWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
98 changes: 98 additions & 0 deletions modules/express/test/unit/typedRoutes/generateWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down
8 changes: 8 additions & 0 deletions modules/sdk-core/src/bitgo/keychain/iKeychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { BitGoKeyFromOvcShares, OvcToBitGoJSON } from './ovcJsonCodec';

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

export interface WebauthnInfo {
prfSalt: string;
otpDeviceId: string;
encryptedPrv: string;
}

export type SourceType = 'bitgo' | 'backup' | 'user' | 'cold';

export type WebauthnFmt = 'none' | 'packed' | 'fido-u2f';
Expand Down Expand Up @@ -132,6 +138,8 @@ 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?: WebauthnInfo[];
}

export interface ApiKeyShare {
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/keychain/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export class Keychains implements IKeychains {
isDistributedCustody: params.isDistributedCustody,
isMPCv2: params.isMPCv2,
coinSpecific: params.coinSpecific,
webauthnDevices: params.webauthnDevices,
})
.result();
}
Expand Down
20 changes: 14 additions & 6 deletions modules/sdk-core/src/bitgo/wallet/iWallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as t from 'io-ts';

import { IRequestTracer } from '../../api';
import { KeychainsTriplet, LightningKeychainsTriplet } from '../baseCoin';
import { Keychain } from '../keychain';
import { Keychain, WebauthnInfo } from '../keychain';
import { IWallet, PaginationOptions, WalletShare } from './iWallet';
import { Wallet } from './wallet';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -161,11 +173,7 @@ export interface AcceptShareOptionsRequest {
* PRF-derived passphrase so the server can store a WebAuthn-protected copy.
* The passphrase itself is never sent to the server.
*/
webauthnInfo?: {
otpDeviceId: string;
prfSalt: string;
encryptedPrv: string;
};
webauthnInfo?: WebauthnInfo;
}

export interface BulkUpdateWalletShareOptions {
Expand Down
15 changes: 15 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading