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
2 changes: 1 addition & 1 deletion modules/sdk-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
]
},
"dependencies": {
"@bitgo/public-types": "5.96.2",
"@bitgo/public-types": "6.1.0",
"@bitgo/sdk-lib-mpc": "^10.11.0",
"@bitgo/secp256k1": "^1.11.0",
"@bitgo/sjcl": "^1.1.0",
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from './market';
export * from './pendingApproval';
export { WalletProofs } from './proofs';
export * from './recovery';
export * from './passkey';
export * from './staking';
export * from './trading';
export * from './tss';
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './types';
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
50 changes: 50 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/prfHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { KeychainWebauthnDevice } from '../keychain/iKeychains';

/**
* Builds the evalByCredential map and a credId-to-device lookup map.
*
* Each device's credID is base64url-encoded, and prfSalt is enterprise-scoped.
* Devices without a prfSalt are skipped — they cannot participate in PRF eval.
*
* Copied from retail: buildEvalByCredentialFromKeychain()
*
* @param devices - webauthnDevices from the wallet keychain
*/
export function buildEvalByCredential(devices: KeychainWebauthnDevice[]): {
evalByCredential: Record<string, string>;
credIdToDevice: Map<string, KeychainWebauthnDevice>;
} {
const evalByCredential: Record<string, string> = {};
const credIdToDevice = new Map<string, KeychainWebauthnDevice>();

for (const device of devices) {
if (!device.prfSalt) continue;

const { credID } = device.authenticatorInfo;
evalByCredential[credID] = device.prfSalt;
credIdToDevice.set(credID, device);
}

return { evalByCredential, credIdToDevice };
}

/**
* Finds the KeychainWebauthnDevice whose credID matches the credential ID
* returned by the WebAuthn assertion.
*
* @param devices - webauthnDevices from the wallet keychain
* @param credentialId - base64url credential ID from the WebAuthn assertion
* @throws if no device matches
*/
export function matchDeviceByCredentialId(
devices: KeychainWebauthnDevice[],
credentialId: string
): KeychainWebauthnDevice {
// Rebuilds the eval map to reuse the credIdToDevice lookup — device lists are small so this is fine.
const { credIdToDevice } = buildEvalByCredential(devices);
const device = credIdToDevice.get(credentialId);
if (!device) {
throw new Error('Could not identify which passkey device was used');
}
return device;
}
21 changes: 21 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type { WebAuthnOtpDevice } from '@bitgo/public-types';

export interface PasskeyAuthResult {
/** Raw PRF output — undefined if the authenticator does not support PRF */
prfResult: ArrayBuffer | undefined;
/** base64url credential ID returned by the authenticator — matches WebAuthnOtpDevice.credentialId */
credentialId: string;
/** JSON-stringified WebAuthn assertion — pass to sdk.unlock({ otp: otpCode }) */
otpCode: string;
}

export interface PasskeyGetOptions {
publicKey: PublicKeyCredentialRequestOptions;
/** PRF eval map: { [credentialId]: enterpriseSalt } — passed to the PRF extension */
evalByCredential?: Record<string, string>;
}

export interface WebAuthnProvider {
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential>;
get(options: PasskeyGetOptions): Promise<PasskeyAuthResult>;
}
79 changes: 79 additions & 0 deletions modules/sdk-core/test/unit/bitgo/passkey/prfHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as assert from 'assert';
import { buildEvalByCredential, matchDeviceByCredentialId } from '../../../../src/bitgo/passkey/prfHelpers';
import { KeychainWebauthnDevice } from '../../../../src/bitgo/keychain/iKeychains';

const device1: KeychainWebauthnDevice = {
otpDeviceId: 'oid-1',
authenticatorInfo: { credID: 'cred-aaa', fmt: 'none', publicKey: 'pk-1' },
prfSalt: 'salt-aaa',
encryptedPrv: 'enc-prv-1',
};

const device2: KeychainWebauthnDevice = {
otpDeviceId: 'oid-2',
authenticatorInfo: { credID: 'cred-bbb', fmt: 'none', publicKey: 'pk-2' },
prfSalt: 'salt-bbb',
encryptedPrv: 'enc-prv-2',
};

describe('buildEvalByCredential', function () {
it('maps each device credID to its prfSalt in evalByCredential', function () {
const { evalByCredential } = buildEvalByCredential([device1, device2]);
assert.deepStrictEqual(evalByCredential, {
'cred-aaa': 'salt-aaa',
'cred-bbb': 'salt-bbb',
});
});

it('populates credIdToDevice with both devices', function () {
const { credIdToDevice } = buildEvalByCredential([device1, device2]);
assert.strictEqual(credIdToDevice.get('cred-aaa'), device1);
assert.strictEqual(credIdToDevice.get('cred-bbb'), device2);
});

it('returns empty maps for an empty device list', function () {
const { evalByCredential, credIdToDevice } = buildEvalByCredential([]);
assert.deepStrictEqual(evalByCredential, {});
assert.strictEqual(credIdToDevice.size, 0);
});

it('skips devices with empty prfSalt', function () {
const deviceNoPrf = { ...device1, prfSalt: '' };
const { evalByCredential, credIdToDevice } = buildEvalByCredential([deviceNoPrf, device2]);
assert.deepStrictEqual(evalByCredential, { 'cred-bbb': 'salt-bbb' });
assert.strictEqual(credIdToDevice.has('cred-aaa'), false);
});

it('skips devices with undefined prfSalt', function () {
const deviceNoPrf = { ...device1, prfSalt: undefined as unknown as string };
const { evalByCredential, credIdToDevice } = buildEvalByCredential([deviceNoPrf, device2]);
assert.deepStrictEqual(evalByCredential, { 'cred-bbb': 'salt-bbb' });
assert.strictEqual(credIdToDevice.has('cred-aaa'), false);
});
});

describe('matchDeviceByCredentialId', function () {
it('returns the matching device', function () {
const result = matchDeviceByCredentialId([device1, device2], 'cred-bbb');
assert.strictEqual(result, device2);
});

it('returns the first device when it matches', function () {
const result = matchDeviceByCredentialId([device1, device2], 'cred-aaa');
assert.strictEqual(result, device1);
});

it('throws with the retail error message when no device matches', function () {
assert.throws(
() => matchDeviceByCredentialId([device1, device2], 'cred-unknown'),
(err: Error) => {
assert.strictEqual(err.message, 'Could not identify which passkey device was used');
return true;
}
);
});

it('throws when the device list is empty', function () {
assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error);
});
});
Loading