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';
39 changes: 39 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/prfHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { KeychainWebauthnDevice } from '../keychain/iKeychains';

/**
* Builds the PRF eval map and credential-to-device lookup from a wallet keychain's webauthn devices.
* Devices without a prfSalt are skipped.
*/
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 };
}

/**
* Returns the webauthn device matching the given credential ID.
* @throws if no matching device is found
*/
export function matchDeviceByCredentialId(
devices: KeychainWebauthnDevice[],
credentialId: string
): KeychainWebauthnDevice {
const { credIdToDevice } = buildEvalByCredential(devices);
const device = credIdToDevice.get(credentialId);
if (!device) {
throw new Error('Could not identify which passkey device was used');
}
return device;
}
24 changes: 24 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export type { WebAuthnOtpDevice } from '@bitgo/public-types';

/** Result of a WebAuthn assertion with the PRF extension. */
export interface PasskeyAuthResult {
// raw PRF output; undefined if the authenticator does not support PRF
prfResult: ArrayBuffer | undefined;
// base64url credential ID identifying which passkey was used
credentialId: string;
// OTP code from the assertion
otpCode: string;
}

/** Options for WebAuthnProvider.get(). */
export interface PasskeyGetOptions {
publicKey: PublicKeyCredentialRequestOptions;
// PRF eval map: { [credentialId]: prfSalt }
evalByCredential?: Record<string, string>;
}

/** Abstraction over the WebAuthn credential API. Inject a mock in tests. */
export interface WebAuthnProvider {
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential>;
get(options: PasskeyGetOptions): Promise<PasskeyAuthResult>;
Comment on lines +22 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detail what these methods do and what they should be used for.

}
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