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
3 changes: 1 addition & 2 deletions modules/passkey-crypto/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@
},
"dependencies": {
"@bitgo/public-types": "6.1.0",
"@bitgo/sdk-core": "^36.42.0",
"@bitgo/sjcl": "^1.1.0"
"@bitgo/sdk-core": "^36.44.0"
},
"devDependencies": {
"@types/node": "^18.0.0"
Expand Down
88 changes: 88 additions & 0 deletions modules/passkey-crypto/src/attachPasskeyToWallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { BitGoBase, Keychain } from '@bitgo/sdk-core';
import { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
import { derivePassword } from './derivePassword';
import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes';

export async function attachPasskeyToWallet(params: {
bitgo: BitGoBase;
coin: string;
walletId: string;
device: WebAuthnOtpDevice;
existingPassphrase: string;
provider: WebAuthnProvider;
}): Promise<Keychain> {
const { bitgo, coin, walletId, device, existingPassphrase, provider } = params;

// Throw early if PRF extension is not supported
if (!device.prfSalt) {
throw new Error('PRF extension not supported by this device. Please use a different passkey.');
}

const baseCoin = bitgo.coin(coin);

// Fetch wallet and validate it is a hot wallet
const wallet = await baseCoin.wallets().get({ id: walletId });

if (wallet.type() !== 'hot') {
throw new Error(`Wallet ${walletId} is not a hot wallet. Only hot wallets support passkey attachment.`);
}

const walletData = wallet.toJSON();
const enterpriseId = walletData.enterprise;
if (!enterpriseId) {
throw new Error(`Wallet ${walletId} has no enterprise.`);
}

// Fetch the user keychain — iterates keys until it finds one with encryptedPrv
const keychain = await wallet.getEncryptedUserKeychain();
const keychainId = keychain.id;

// Derive enterprise-scoped salt
const enterpriseSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId);

// Decrypt private key with existing passphrase
const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv });

// Decode credentialId from base64url to ArrayBuffer for allowCredentials.
// The WebAuthn spec requires allowCredentials to be non-empty when using evalByCredential,
// and each entry must correspond to a key in the evalByCredential map.
const credentialIdBuffer = Buffer.from(device.credentialId.replace(/-/g, '+').replace(/_/g, '/'), 'base64').buffer;

// PRF assertion — evalByCredential maps this device's credentialId to its enterprise salt
const authResult = await provider.get({
publicKey: {
allowCredentials: [{ type: 'public-key', id: credentialIdBuffer }],
} as PublicKeyCredentialRequestOptions,
evalByCredential: { [device.credentialId]: enterpriseSalt },
});

if (!authResult.prfResult) {
throw new Error('PRF assertion did not return a result.');
}

// Derive password from PRF output and re-encrypt
const prfPassword = derivePassword(authResult.prfResult);
const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey });

// Convert enterpriseSalt from hex to base64url (URL-safe, no padding)
// as required by the server's prfSalt validation.
const prfSaltBase64url = Buffer.from(enterpriseSalt, 'hex')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');

// PUT webauthnInfo to keychain endpoint
const updatedKeychain = await bitgo
.put(bitgo.url(`/${coin}/key/${keychainId}`, 2))
.send({
webauthnInfo: {
prfSalt: prfSaltBase64url,
otpDeviceId: device.id,
encryptedPrv,
},
})
.result();

return updatedKeychain as Keychain;
}
22 changes: 4 additions & 18 deletions modules/passkey-crypto/src/deriveEnterpriseSalt.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import * as sjcl from '@bitgo/sjcl';
import type { SjclCodecs, SjclHashes, SjclMisc } from '@bitgo/sjcl';

type SjclType = {
hash: SjclHashes;
codec: SjclCodecs;
misc: SjclMisc;
};
import { createHmac } from 'crypto';

/**
* Derives an enterprise-scoped PRF salt to prevent cross-enterprise key reuse.
Expand All @@ -15,16 +8,9 @@ type SjclType = {
*
* @param baseSalt - Server-provided base64url-encoded PRF salt
* @param enterpriseId - Enterprise identifier
* @returns Base64-encoded HMAC-SHA256 digest
* @returns Hex-encoded HMAC-SHA256 digest
*/
export function deriveEnterpriseSalt(baseSalt: string, enterpriseId: string): string {
const { misc, codec, hash } = sjcl as unknown as SjclType;

const keyBits = codec.base64url.toBits(baseSalt);
const dataBits = codec.utf8String.toBits(enterpriseId);

const hmacInstance = new misc.hmac(keyBits, hash.sha256);
const resultBits = hmacInstance.mac(dataBits);

return codec.base64.fromBits(resultBits);
const keyBytes = Buffer.from(baseSalt.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
return createHmac('sha256', keyBytes).update(enterpriseId).digest('hex');
}
247 changes: 247 additions & 0 deletions modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import * as assert from 'assert';
import * as sinon from 'sinon';
import { attachPasskeyToWallet } from '../../src/attachPasskeyToWallet';
import { WebAuthnOtpDevice, PasskeyAuthResult, WebAuthnProvider } from '../../src/webAuthnTypes';

describe('attachPasskeyToWallet', function () {
const coin = 'tbtc';
const walletId = 'wallet-abc123';
const keychainId = 'key-user-id';
const enterpriseId = 'enterprise-xyz';
const encryptedPrv = 'encrypted-prv-string';
const decryptedPrv = 'xprv-decrypted';
const existingPassphrase = 'correct-passphrase';
const reEncryptedPrv = 're-encrypted-prv';

const prfResultBuffer = new Uint8Array([0x1e, 0x5c, 0xb4, 0x78]).buffer;

const device: WebAuthnOtpDevice = {
id: 'mongo-object-id-123',
credentialId: 'cred-id-456',
prfSalt: 'ZqJ64M2dL65zn2-Jxd58SMN2ILc9QjbCFxUTGHd_LC8',
isPasskey: true,
};

const mockAuthResult: PasskeyAuthResult = {
prfResult: prfResultBuffer,
credentialId: 'cred-id-456',
otpCode: '123456',
};

const updatedKeychain = {
id: keychainId,
pub: 'xpub-123',
type: 'independent' as const,
encryptedPrv,
};

let mockWallet: {
type: sinon.SinonStub;
toJSON: sinon.SinonStub;
getEncryptedUserKeychain: sinon.SinonStub;
};

let mockWallets: {
get: sinon.SinonStub;
};

let mockBaseCoin: {
wallets: sinon.SinonStub;
};

let mockBitGo: {
url: sinon.SinonStub;
coin: sinon.SinonStub;
put: sinon.SinonStub;
decrypt: sinon.SinonStub;
encrypt: sinon.SinonStub;
};

let mockProvider: {
create: sinon.SinonStub;
get: sinon.SinonStub;
};

beforeEach(function () {
mockWallet = {
type: sinon.stub().returns('hot'),
toJSON: sinon.stub().returns({ enterprise: enterpriseId }),
getEncryptedUserKeychain: sinon.stub().resolves({ id: keychainId, encryptedPrv }),
};

mockWallets = {
get: sinon.stub().resolves(mockWallet),
};

mockBaseCoin = {
wallets: sinon.stub().returns(mockWallets),
};

mockBitGo = {
url: sinon
.stub<[path: string, version?: number], string>()
.callsFake((path, version) => `/api/v${version ?? 1}${path}`),
coin: sinon.stub().returns(mockBaseCoin),
put: sinon.stub(),
decrypt: sinon.stub(),
encrypt: sinon.stub(),
};

mockProvider = {
create: sinon.stub(),
get: sinon.stub(),
};

mockBitGo.decrypt.returns(decryptedPrv);
mockBitGo.encrypt.returns(reEncryptedPrv);

const putSendStub = sinon.stub().returns({ result: sinon.stub().resolves(updatedKeychain) });
mockBitGo.put.returns({ send: putSendStub });

mockProvider.get.resolves(mockAuthResult);
});

afterEach(function () {
sinon.restore();
});

async function callAttach(overrides?: Partial<Parameters<typeof attachPasskeyToWallet>[0]>) {
return attachPasskeyToWallet({
bitgo: mockBitGo as unknown as Parameters<typeof attachPasskeyToWallet>[0]['bitgo'],
coin,
walletId,
device,
existingPassphrase,
provider: mockProvider as unknown as WebAuthnProvider,
...overrides,
});
}

it('should attach a passkey and return the updated keychain', async function () {
const result = await callAttach();

sinon.assert.calledWith(mockBitGo.coin, coin);
sinon.assert.calledWith(mockWallets.get, { id: walletId });
sinon.assert.calledOnce(mockWallet.type);
sinon.assert.calledOnce(mockWallet.getEncryptedUserKeychain);
sinon.assert.calledOnce(mockBitGo.decrypt);
sinon.assert.calledWithExactly(mockBitGo.decrypt, { password: existingPassphrase, input: encryptedPrv });

// provider.get called with evalByCredential keyed on device.credentialId
sinon.assert.calledOnce(mockProvider.get);
const getArgs = mockProvider.get.firstCall.args[0];
assert.ok(getArgs.evalByCredential);
assert.strictEqual(typeof getArgs.evalByCredential[device.credentialId], 'string');

// allowCredentials must be populated with the credential ID as an ArrayBuffer
assert.ok(Array.isArray(getArgs.publicKey.allowCredentials));
assert.strictEqual(getArgs.publicKey.allowCredentials.length, 1);
assert.strictEqual(getArgs.publicKey.allowCredentials[0].type, 'public-key');
assert.ok(getArgs.publicKey.allowCredentials[0].id instanceof ArrayBuffer);

// PUT called with correct shape
sinon.assert.calledOnce(mockBitGo.put);
sinon.assert.calledWith(mockBitGo.put, `/api/v2/${coin}/key/${keychainId}`);
const sendStub = mockBitGo.put.firstCall.returnValue.send;
sinon.assert.calledOnce(sendStub);
const putBody = sendStub.firstCall.args[0];
assert.ok(putBody.webauthnInfo);
assert.strictEqual(putBody.webauthnInfo.otpDeviceId, device.id);
// prfSalt must be base64url (URL-safe, no padding) as required by server validation
assert.match(putBody.webauthnInfo.prfSalt, /^[A-Za-z0-9\-_]+$/);
assert.strictEqual(typeof putBody.webauthnInfo.encryptedPrv, 'string');

assert.strictEqual(result.id, keychainId);
});

it('should decode credentialId containing base64url-specific characters (- and _)', async function () {
const deviceWithUrlChars: WebAuthnOtpDevice = {
...device,
credentialId: 'abc-def_ghi+jkl',
};

const result = await callAttach({ device: deviceWithUrlChars });
assert.ok(result);

const getArgs = mockProvider.get.firstCall.args[0];
assert.ok(getArgs.publicKey.allowCredentials[0].id instanceof ArrayBuffer);
});

it('should throw if device.prfSalt is undefined', async function () {
const deviceNoPrf: WebAuthnOtpDevice = { ...device, prfSalt: undefined };

await assert.rejects(
() => callAttach({ device: deviceNoPrf }),
(err: Error) => {
assert.strictEqual(err.message, 'PRF extension not supported by this device. Please use a different passkey.');
return true;
}
);

sinon.assert.notCalled(mockBitGo.coin);
sinon.assert.notCalled(mockBitGo.put);
});

it('should throw if wallet is not a hot wallet', async function () {
mockWallet.type.returns('cold');

await assert.rejects(
() => callAttach(),
(err: Error) => {
assert.ok(err.message.includes('not a hot wallet'));
return true;
}
);

sinon.assert.notCalled(mockBitGo.put);
});

it('should throw if wallet has no enterprise', async function () {
mockWallet.toJSON.returns({ enterprise: undefined });

await assert.rejects(
() => callAttach(),
(err: Error) => {
assert.ok(err.message.includes('has no enterprise'));
return true;
}
);
});

it('should throw if PRF assertion returns no result', async function () {
mockProvider.get.resolves({ ...mockAuthResult, prfResult: undefined });

await assert.rejects(
() => callAttach(),
(err: Error) => {
assert.ok(err.message.includes('PRF assertion did not return a result'));
return true;
}
);

sinon.assert.notCalled(mockBitGo.put);
});

it('should propagate decrypt errors', async function () {
mockBitGo.decrypt.throws(new Error('decryption failed'));

await assert.rejects(
() => callAttach(),
(err: Error) => {
assert.ok(err.message.includes('decryption failed'));
return true;
}
);

sinon.assert.notCalled(mockBitGo.put);
});

it('should use device.credentialId as the key in evalByCredential', async function () {
await callAttach();

const getArgs = mockProvider.get.firstCall.args[0];
const evalKeys = Object.keys(getArgs.evalByCredential);
assert.strictEqual(evalKeys.length, 1);
assert.strictEqual(evalKeys[0], device.credentialId);
});
});
Loading
Loading