Skip to content

Commit b89ec3a

Browse files
committed
feat(sdk-core): add passkey types, webauthn provider interface, and prf helpers
- WebAuthnOtpDevice: registration-flow type with credID, fmt, publicKey, prfSalt?, encryptedPrv? - PasskeyAuthResult: mirrors retail ChallengeFor2FAWithPrfResult exactly - WebAuthnProvider: browser abstraction returning PublicKeyCredential | null (handles cancellation) - buildEvalByCredential: takes KeychainWebauthnDevice[], reads authenticatorInfo.credID (matches retail) - matchDeviceByCredentialId: takes KeychainWebauthnDevice[], throws with known IDs on miss - export * from './passkey' added to bitgo/index.ts barrel - unit tests: 7 cases covering happy path, empty list, and descriptive error content TICKET: WCN-187
1 parent 4b63462 commit b89ec3a

5 files changed

Lines changed: 133 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 { WebAuthnOtpDevice, PasskeyAuthResult, WebAuthnProvider } from './types';
2+
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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(devices.map((d) => [d.authenticatorInfo.credID, d.prfSalt]));
16+
}
17+
18+
/**
19+
* Finds the KeychainWebauthnDevice whose credID matches the credential ID
20+
* returned by the WebAuthn assertion.
21+
*
22+
* @param devices - webauthnDevices from the wallet keychain
23+
* @param credentialId - base64url credential ID from the WebAuthn assertion
24+
* @throws if no device matches
25+
*/
26+
export function matchDeviceByCredentialId(
27+
devices: KeychainWebauthnDevice[],
28+
credentialId: string
29+
): KeychainWebauthnDevice {
30+
const device = devices.find((d) => d.authenticatorInfo.credID === credentialId);
31+
if (!device) {
32+
throw new Error(
33+
`No passkey device found matching credential ID "${credentialId}". ` +
34+
`Known credential IDs: [${devices.map((d) => d.authenticatorInfo.credID).join(', ')}]`
35+
);
36+
}
37+
return device;
38+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export interface WebAuthnOtpDevice {
2+
/** MongoDB ObjectId — used for deletion API calls */
3+
otpDeviceId: string;
4+
/** base64url WebAuthn credential ID */
5+
credID: string;
6+
/** WebAuthn attestation format */
7+
fmt: 'none' | 'packed' | 'fido-u2f';
8+
/** Base64-encoded public key from the authenticator */
9+
publicKey: string;
10+
/** base64url-encoded salt from the server — optional */
11+
prfSalt?: string;
12+
/** SJCL-encrypted private key (present once passkey is attached to a wallet keychain) */
13+
encryptedPrv?: string;
14+
}
15+
16+
export interface PasskeyAuthResult {
17+
/** Raw PRF output — undefined if the authenticator does not support PRF */
18+
prfResult: ArrayBuffer | undefined;
19+
/** base64url credential ID returned by the authenticator — matches KeychainWebauthnDevice.authenticatorInfo.credID */
20+
credentialId: string;
21+
/** JSON-stringified WebAuthn assertion — pass to sdk.unlock({ otp: otpCode }) */
22+
otpCode: string;
23+
}
24+
25+
export interface WebAuthnProvider {
26+
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential | null>;
27+
get(options: PublicKeyCredentialRequestOptions): Promise<PublicKeyCredential | null>;
28+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
38+
describe('matchDeviceByCredentialId', function () {
39+
it('returns the matching device', function () {
40+
const result = matchDeviceByCredentialId([device1, device2], 'cred-bbb');
41+
assert.strictEqual(result, device2);
42+
});
43+
44+
it('returns the first device when it matches', function () {
45+
const result = matchDeviceByCredentialId([device1, device2], 'cred-aaa');
46+
assert.strictEqual(result, device1);
47+
});
48+
49+
it('throws a descriptive error when no device matches', function () {
50+
assert.throws(
51+
() => matchDeviceByCredentialId([device1, device2], 'cred-unknown'),
52+
(err: Error) => {
53+
assert.ok(err.message.includes('cred-unknown'));
54+
assert.ok(err.message.includes('cred-aaa'));
55+
assert.ok(err.message.includes('cred-bbb'));
56+
return true;
57+
}
58+
);
59+
});
60+
61+
it('throws when the device list is empty', function () {
62+
assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error);
63+
});
64+
});

0 commit comments

Comments
 (0)