Skip to content
Open
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
9 changes: 9 additions & 0 deletions modules/sdk-core/src/bitgo/keychain/iKeychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallets.ts
Original file line number Diff line number Diff line change
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
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
190 changes: 190 additions & 0 deletions modules/sdk-core/test/unit/bitgo/wallet/walletsWebauthn.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading