Skip to content

Commit bb0c324

Browse files
committed
feat(sdk-core): add passkey types, webauthn provider interface, and prf helpers
- PasskeyDevice, PasskeyAuthResult, WebAuthnProvider interfaces in passkey/types.ts - prfSalt optional on PasskeyDevice (matches retail WebAuthnOtpDevice) - prfResult: ArrayBuffer | undefined on PasskeyAuthResult (matches ChallengeFor2FAWithPrfResult) - buildEvalByCredential takes KeychainWebauthnDevice[], reads authenticatorInfo.credID - matchDeviceByCredentialId takes KeychainWebauthnDevice[], throws with known IDs on miss - export * from './passkey' added to bitgo/index.ts barrel - unit tests: 9 cases covering happy path, empty list, missing salt, descriptive error content TICKET: WCN-187
1 parent 4b63462 commit bb0c324

5 files changed

Lines changed: 142 additions & 0 deletions

File tree

modules/sdk-core/src/bitgo/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export * from './market';
2020
export * from './pendingApproval';
2121
export { WalletProofs } from './proofs';
2222
export * from './recovery';
23+
export * from './passkey';
2324
export * from './staking';
2425
export * from './trading';
2526
export * from './tss';
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { KeychainWebauthnDevice } from '../keychain/iKeychains';
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+
* Mirrors retail's buildEvalByCredentialFromKeychain — takes KeychainWebauthnDevice[]
9+
* directly so credID is read from authenticatorInfo where it actually lives.
10+
*
11+
* @param devices - webauthnDevices from the wallet keychain
12+
* @returns a map of { [credID]: prfSalt }
13+
*/
14+
export function buildEvalByCredential(devices: KeychainWebauthnDevice[]): Record<string, string> {
15+
return Object.fromEntries(
16+
devices.filter((d) => d.prfSalt !== undefined).map((d) => [d.authenticatorInfo.credID, d.prfSalt as string])
17+
);
18+
}
19+
20+
/**
21+
* Finds the KeychainWebauthnDevice whose credID matches the credential ID
22+
* returned by the WebAuthn assertion.
23+
*
24+
* @param devices - webauthnDevices from the wallet keychain
25+
* @param credentialId - base64url credential ID from the WebAuthn assertion
26+
* @throws if no device matches
27+
*/
28+
export function matchDeviceByCredentialId(
29+
devices: KeychainWebauthnDevice[],
30+
credentialId: string
31+
): KeychainWebauthnDevice {
32+
const device = devices.find((d) => d.authenticatorInfo.credID === credentialId);
33+
if (!device) {
34+
throw new Error(
35+
`No passkey device found matching credential ID "${credentialId}". ` +
36+
`Known credential IDs: [${devices.map((d) => d.authenticatorInfo.credID).join(', ')}]`
37+
);
38+
}
39+
return device;
40+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export interface PasskeyDevice {
2+
/** MongoDB ObjectId — used for deletion API calls */
3+
otpDeviceId: string;
4+
/** base64url WebAuthn credential ID — used to construct authenticatorInfo when attaching to keychain */
5+
credID: string;
6+
/** base64url-encoded salt from the server — optional, matches WebAuthnOtpDevice */
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 — undefined if the authenticator does not support PRF */
14+
prfResult: ArrayBuffer | undefined;
15+
/** base64url credential ID returned by the authenticator — matches KeychainWebauthnDevice.authenticatorInfo.credID */
16+
credentialId: string;
17+
/** JSON-stringified WebAuthn assertion — pass to sdk.unlock({ otp: otpCode }) */
18+
otpCode: string;
19+
}
20+
21+
export interface WebAuthnProvider {
22+
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential>;
23+
get(options: PublicKeyCredentialRequestOptions): Promise<PublicKeyCredential>;
24+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as assert from 'assert';
2+
import { buildEvalByCredential, matchDeviceByCredentialId } from '../../../../src/bitgo/passkey/prfHelpers';
3+
import { KeychainWebauthnDevice } from '../../../../src/bitgo/keychain/iKeychains';
4+
5+
const device1: KeychainWebauthnDevice = {
6+
otpDeviceId: 'oid-1',
7+
authenticatorInfo: { credID: 'cred-aaa', fmt: 'none', publicKey: 'pk-1' },
8+
prfSalt: 'salt-aaa',
9+
encryptedPrv: 'enc-prv-1',
10+
};
11+
12+
const device2: KeychainWebauthnDevice = {
13+
otpDeviceId: 'oid-2',
14+
authenticatorInfo: { credID: 'cred-bbb', fmt: 'none', publicKey: 'pk-2' },
15+
prfSalt: 'salt-bbb',
16+
encryptedPrv: 'enc-prv-2',
17+
};
18+
19+
describe('buildEvalByCredential', function () {
20+
it('maps each device authenticatorInfo.credID to its prfSalt', function () {
21+
const result = buildEvalByCredential([device1, device2]);
22+
assert.deepStrictEqual(result, {
23+
'cred-aaa': 'salt-aaa',
24+
'cred-bbb': 'salt-bbb',
25+
});
26+
});
27+
28+
it('returns an empty object for an empty device list', function () {
29+
assert.deepStrictEqual(buildEvalByCredential([]), {});
30+
});
31+
32+
it('returns a single-entry map for one device', function () {
33+
const result = buildEvalByCredential([device1]);
34+
assert.deepStrictEqual(result, { 'cred-aaa': 'salt-aaa' });
35+
});
36+
37+
it('omits devices with no prfSalt', function () {
38+
const deviceNoSalt = { ...device2, prfSalt: undefined } as unknown as KeychainWebauthnDevice;
39+
const result = buildEvalByCredential([device1, deviceNoSalt]);
40+
assert.deepStrictEqual(result, { 'cred-aaa': 'salt-aaa' });
41+
});
42+
43+
it('returns an empty object when no devices have a prfSalt', function () {
44+
const deviceNoSalt = { ...device1, prfSalt: undefined } as unknown as KeychainWebauthnDevice;
45+
assert.deepStrictEqual(buildEvalByCredential([deviceNoSalt]), {});
46+
});
47+
});
48+
49+
describe('matchDeviceByCredentialId', function () {
50+
it('returns the matching device', function () {
51+
const result = matchDeviceByCredentialId([device1, device2], 'cred-bbb');
52+
assert.strictEqual(result, device2);
53+
});
54+
55+
it('returns the first device when it matches', function () {
56+
const result = matchDeviceByCredentialId([device1, device2], 'cred-aaa');
57+
assert.strictEqual(result, device1);
58+
});
59+
60+
it('throws a descriptive error when no device matches', function () {
61+
assert.throws(
62+
() => matchDeviceByCredentialId([device1, device2], 'cred-unknown'),
63+
(err: Error) => {
64+
assert.ok(err.message.includes('cred-unknown'));
65+
assert.ok(err.message.includes('cred-aaa'));
66+
assert.ok(err.message.includes('cred-bbb'));
67+
return true;
68+
}
69+
);
70+
});
71+
72+
it('throws when the device list is empty', function () {
73+
assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error);
74+
});
75+
});

0 commit comments

Comments
 (0)