Skip to content

Commit 6e512c4

Browse files
committed
feat(sdk-core): add passkey types, webauthn provider interface, and prf helpers
- WebAuthnOtpDevice: imported from @bitgo/public-types@6.1.0 (id, credentialId, prfSalt?) - PasskeyAuthResult: stays local (ArrayBuffer not encodable in io-ts) - WebAuthnProvider: create() -> PublicKeyCredential, get() -> PasskeyAuthResult - buildEvalByCredential: returns { evalByCredential, credIdToDevice }, skips no-prfSalt devices - matchDeviceByCredentialId: uses credIdToDevice map, throws retail error message - export * from './passkey' added to bitgo/index.ts barrel - unit tests: 8 cases covering happy path, empty list, prfSalt skip, and error message TICKET: WCN-187
1 parent 98f844b commit 6e512c4

6 files changed

Lines changed: 146 additions & 1 deletion

File tree

modules/sdk-core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
]
4141
},
4242
"dependencies": {
43-
"@bitgo/public-types": "5.96.2",
43+
"@bitgo/public-types": "6.1.0",
4444
"@bitgo/sdk-lib-mpc": "^10.11.0",
4545
"@bitgo/secp256k1": "^1.11.0",
4646
"@bitgo/sjcl": "^1.1.0",

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, PasskeyGetOptions, WebAuthnProvider } from './types';
2+
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { KeychainWebauthnDevice } from '../keychain/iKeychains';
2+
3+
/**
4+
* Builds the PRF eval map and credential-to-device lookup from a wallet keychain's webauthn devices.
5+
* Devices without a prfSalt are skipped.
6+
*/
7+
export function buildEvalByCredential(devices: KeychainWebauthnDevice[]): {
8+
evalByCredential: Record<string, string>;
9+
credIdToDevice: Map<string, KeychainWebauthnDevice>;
10+
} {
11+
const evalByCredential: Record<string, string> = {};
12+
const credIdToDevice = new Map<string, KeychainWebauthnDevice>();
13+
14+
for (const device of devices) {
15+
if (!device.prfSalt) continue;
16+
17+
const { credID } = device.authenticatorInfo;
18+
evalByCredential[credID] = device.prfSalt;
19+
credIdToDevice.set(credID, device);
20+
}
21+
22+
return { evalByCredential, credIdToDevice };
23+
}
24+
25+
/**
26+
* Returns the webauthn device matching the given credential ID.
27+
* @throws if no matching device is found
28+
*/
29+
export function matchDeviceByCredentialId(
30+
devices: KeychainWebauthnDevice[],
31+
credentialId: string
32+
): KeychainWebauthnDevice {
33+
const { credIdToDevice } = buildEvalByCredential(devices);
34+
const device = credIdToDevice.get(credentialId);
35+
if (!device) {
36+
throw new Error('Could not identify which passkey device was used');
37+
}
38+
return device;
39+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export type { WebAuthnOtpDevice } from '@bitgo/public-types';
2+
3+
/** Result of a WebAuthn assertion with the PRF extension. */
4+
export interface PasskeyAuthResult {
5+
// raw PRF output; undefined if the authenticator does not support PRF
6+
prfResult: ArrayBuffer | undefined;
7+
// base64url credential ID identifying which passkey was used
8+
credentialId: string;
9+
// OTP code from the assertion
10+
otpCode: string;
11+
}
12+
13+
/** Options for WebAuthnProvider.get(). */
14+
export interface PasskeyGetOptions {
15+
publicKey: PublicKeyCredentialRequestOptions;
16+
// PRF eval map: { [credentialId]: prfSalt }
17+
evalByCredential?: Record<string, string>;
18+
}
19+
20+
/** Abstraction over the WebAuthn credential API. Inject a mock in tests. */
21+
export interface WebAuthnProvider {
22+
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential>;
23+
get(options: PasskeyGetOptions): Promise<PasskeyAuthResult>;
24+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 credID to its prfSalt in evalByCredential', function () {
21+
const { evalByCredential } = buildEvalByCredential([device1, device2]);
22+
assert.deepStrictEqual(evalByCredential, {
23+
'cred-aaa': 'salt-aaa',
24+
'cred-bbb': 'salt-bbb',
25+
});
26+
});
27+
28+
it('populates credIdToDevice with both devices', function () {
29+
const { credIdToDevice } = buildEvalByCredential([device1, device2]);
30+
assert.strictEqual(credIdToDevice.get('cred-aaa'), device1);
31+
assert.strictEqual(credIdToDevice.get('cred-bbb'), device2);
32+
});
33+
34+
it('returns empty maps for an empty device list', function () {
35+
const { evalByCredential, credIdToDevice } = buildEvalByCredential([]);
36+
assert.deepStrictEqual(evalByCredential, {});
37+
assert.strictEqual(credIdToDevice.size, 0);
38+
});
39+
40+
it('skips devices with empty prfSalt', function () {
41+
const deviceNoPrf = { ...device1, prfSalt: '' };
42+
const { evalByCredential, credIdToDevice } = buildEvalByCredential([deviceNoPrf, device2]);
43+
assert.deepStrictEqual(evalByCredential, { 'cred-bbb': 'salt-bbb' });
44+
assert.strictEqual(credIdToDevice.has('cred-aaa'), false);
45+
});
46+
47+
it('skips devices with undefined prfSalt', function () {
48+
const deviceNoPrf = { ...device1, prfSalt: undefined as unknown as string };
49+
const { evalByCredential, credIdToDevice } = buildEvalByCredential([deviceNoPrf, device2]);
50+
assert.deepStrictEqual(evalByCredential, { 'cred-bbb': 'salt-bbb' });
51+
assert.strictEqual(credIdToDevice.has('cred-aaa'), false);
52+
});
53+
});
54+
55+
describe('matchDeviceByCredentialId', function () {
56+
it('returns the matching device', function () {
57+
const result = matchDeviceByCredentialId([device1, device2], 'cred-bbb');
58+
assert.strictEqual(result, device2);
59+
});
60+
61+
it('returns the first device when it matches', function () {
62+
const result = matchDeviceByCredentialId([device1, device2], 'cred-aaa');
63+
assert.strictEqual(result, device1);
64+
});
65+
66+
it('throws with the retail error message when no device matches', function () {
67+
assert.throws(
68+
() => matchDeviceByCredentialId([device1, device2], 'cred-unknown'),
69+
(err: Error) => {
70+
assert.strictEqual(err.message, 'Could not identify which passkey device was used');
71+
return true;
72+
}
73+
);
74+
});
75+
76+
it('throws when the device list is empty', function () {
77+
assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error);
78+
});
79+
});

0 commit comments

Comments
 (0)