Skip to content

Commit a77e258

Browse files
committed
feat(sdk-core): WCN-187 add passkey types, WebAuthnProvider interface, and PRF helpers
- PasskeyDevice, PasskeyAuthResult, WebAuthnProvider types in passkey/types.ts - buildEvalByCredential: maps credId → prfSalt for WebAuthn PRF evalByCredential - matchDeviceByCredentialId: finds device by asserting credential ID, throws descriptively - Unit tests for both PRF helpers (happy path + not-found cases) TICKET: WCN-187
1 parent 9f39d85 commit a77e258

5 files changed

Lines changed: 121 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { PasskeyDevice, PasskeyAuthResult, WebAuthnProvider } from './types';
2+
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { PasskeyDevice } from './types';
2+
3+
/**
4+
* Builds the `evalByCredential` map passed to `WebAuthnProvider.get()`.
5+
* Maps each device's credId to its prfSalt so the authenticator can
6+
* evaluate the PRF with the correct salt for whichever credential it selects.
7+
*
8+
* @param devices - passkey devices stored on the keychain
9+
* @returns a map of { [credId]: prfSalt }
10+
*/
11+
export function buildEvalByCredential(devices: PasskeyDevice[]): Record<string, string> {
12+
return Object.fromEntries(devices.map((d) => [d.credId, d.prfSalt]));
13+
}
14+
15+
/**
16+
* Finds the PasskeyDevice whose credId matches the credential ID returned
17+
* by the WebAuthn assertion.
18+
*
19+
* @param devices - passkey devices stored on the keychain
20+
* @param credentialId - base64url credential ID from the WebAuthn assertion
21+
* @throws if no device matches
22+
*/
23+
export function matchDeviceByCredentialId(devices: PasskeyDevice[], credentialId: string): PasskeyDevice {
24+
const device = devices.find((d) => d.credId === credentialId);
25+
if (!device) {
26+
throw new Error(
27+
`No passkey device found matching credential ID "${credentialId}". ` +
28+
`Known credential IDs: [${devices.map((d) => d.credId).join(', ')}]`
29+
);
30+
}
31+
return device;
32+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export interface PasskeyDevice {
2+
/** MongoDB ObjectId — used for deletion API calls */
3+
otpDeviceId: string;
4+
/** base64url WebAuthn credential ID — used for PRF evalByCredential map */
5+
credId: string;
6+
/** Enterprise-scoped PRF salt stored on the keychain */
7+
prfSalt: string;
8+
/** SJCL-encrypted private key (present once passkey is attached to a wallet keychain) */
9+
encryptedPrv?: string;
10+
}
11+
12+
export interface PasskeyAuthResult {
13+
/** Raw PRF output from a WebAuthn assertion */
14+
prfResult: ArrayBuffer;
15+
/** base64url credential ID returned by the authenticator — matches PasskeyDevice.credId */
16+
credentialId: string;
17+
otpCode?: string;
18+
}
19+
20+
export interface WebAuthnProvider {
21+
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential>;
22+
get(options: PublicKeyCredentialRequestOptions): Promise<PublicKeyCredential>;
23+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { WebAuthnProvider } from './types';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as assert from 'assert';
2+
import { buildEvalByCredential, matchDeviceByCredentialId } from '../../../../src/bitgo/passkey/prfHelpers';
3+
import { PasskeyDevice } from '../../../../src/bitgo/passkey/types';
4+
5+
const device1: PasskeyDevice = {
6+
otpDeviceId: 'oid-1',
7+
credId: 'cred-aaa',
8+
prfSalt: 'salt-aaa',
9+
encryptedPrv: 'enc-prv-1',
10+
};
11+
12+
const device2: PasskeyDevice = {
13+
otpDeviceId: 'oid-2',
14+
credId: 'cred-bbb',
15+
prfSalt: 'salt-bbb',
16+
};
17+
18+
describe('buildEvalByCredential', function () {
19+
it('maps each device credId to its prfSalt', function () {
20+
const result = buildEvalByCredential([device1, device2]);
21+
assert.deepStrictEqual(result, {
22+
'cred-aaa': 'salt-aaa',
23+
'cred-bbb': 'salt-bbb',
24+
});
25+
});
26+
27+
it('returns an empty object for an empty device list', function () {
28+
assert.deepStrictEqual(buildEvalByCredential([]), {});
29+
});
30+
31+
it('returns a single-entry map for one device', function () {
32+
const result = buildEvalByCredential([device1]);
33+
assert.deepStrictEqual(result, { 'cred-aaa': 'salt-aaa' });
34+
});
35+
});
36+
37+
describe('matchDeviceByCredentialId', function () {
38+
it('returns the matching device', function () {
39+
const result = matchDeviceByCredentialId([device1, device2], 'cred-bbb');
40+
assert.strictEqual(result, device2);
41+
});
42+
43+
it('returns the first device when it matches', function () {
44+
const result = matchDeviceByCredentialId([device1, device2], 'cred-aaa');
45+
assert.strictEqual(result, device1);
46+
});
47+
48+
it('throws a descriptive error when no device matches', function () {
49+
assert.throws(
50+
() => matchDeviceByCredentialId([device1, device2], 'cred-unknown'),
51+
(err: Error) => {
52+
assert.ok(err.message.includes('cred-unknown'));
53+
assert.ok(err.message.includes('cred-aaa'));
54+
assert.ok(err.message.includes('cred-bbb'));
55+
return true;
56+
}
57+
);
58+
});
59+
60+
it('throws when the device list is empty', function () {
61+
assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error);
62+
});
63+
});

0 commit comments

Comments
 (0)