Skip to content

Commit 1ccb3cc

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 4b63462 commit 1ccb3cc

6 files changed

Lines changed: 140 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.0",
43+
"@bitgo/public-types": "6.1.0",
4444
"@bitgo/sdk-lib-mpc": "^10.10.2",
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, WebAuthnProvider } from './types';
2+
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { KeychainWebauthnDevice } from '../keychain/iKeychains';
2+
3+
/**
4+
* Builds the evalByCredential map and a credId-to-device lookup map.
5+
*
6+
* Each device's credID is base64url-encoded, and prfSalt is enterprise-scoped.
7+
* Devices without a prfSalt are skipped — they cannot participate in PRF eval.
8+
*
9+
* Copied from retail: buildEvalByCredentialFromKeychain()
10+
*
11+
* @param devices - webauthnDevices from the wallet keychain
12+
*/
13+
export function buildEvalByCredential(devices: KeychainWebauthnDevice[]): {
14+
evalByCredential: Record<string, string>;
15+
credIdToDevice: Map<string, KeychainWebauthnDevice>;
16+
} {
17+
const evalByCredential: Record<string, string> = {};
18+
const credIdToDevice = new Map<string, KeychainWebauthnDevice>();
19+
20+
for (const device of devices) {
21+
if (!device.prfSalt) continue;
22+
23+
const { credID } = device.authenticatorInfo;
24+
evalByCredential[credID] = device.prfSalt;
25+
credIdToDevice.set(credID, device);
26+
}
27+
28+
return { evalByCredential, credIdToDevice };
29+
}
30+
31+
/**
32+
* Finds the KeychainWebauthnDevice whose credID matches the credential ID
33+
* returned by the WebAuthn assertion.
34+
*
35+
* @param devices - webauthnDevices from the wallet keychain
36+
* @param credentialId - base64url credential ID from the WebAuthn assertion
37+
* @throws if no device matches
38+
*/
39+
export function matchDeviceByCredentialId(
40+
devices: KeychainWebauthnDevice[],
41+
credentialId: string
42+
): KeychainWebauthnDevice {
43+
const { credIdToDevice } = buildEvalByCredential(devices);
44+
const device = credIdToDevice.get(credentialId);
45+
if (!device) {
46+
throw new Error('Could not identify which passkey device was used');
47+
}
48+
return device;
49+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type { WebAuthnOtpDevice } from '@bitgo/public-types';
2+
3+
export interface PasskeyAuthResult {
4+
/** Raw PRF output — undefined if the authenticator does not support PRF */
5+
prfResult: ArrayBuffer | undefined;
6+
/** base64url credential ID returned by the authenticator — matches WebAuthnOtpDevice.credentialId */
7+
credentialId: string;
8+
/** JSON-stringified WebAuthn assertion — pass to sdk.unlock({ otp: otpCode }) */
9+
otpCode: string;
10+
}
11+
12+
export interface WebAuthnProvider {
13+
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential>;
14+
get(options: PublicKeyCredentialRequestOptions): Promise<PasskeyAuthResult>;
15+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 without 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+
48+
describe('matchDeviceByCredentialId', function () {
49+
it('returns the matching device', function () {
50+
const result = matchDeviceByCredentialId([device1, device2], 'cred-bbb');
51+
assert.strictEqual(result, device2);
52+
});
53+
54+
it('returns the first device when it matches', function () {
55+
const result = matchDeviceByCredentialId([device1, device2], 'cred-aaa');
56+
assert.strictEqual(result, device1);
57+
});
58+
59+
it('throws with the retail error message when no device matches', function () {
60+
assert.throws(
61+
() => matchDeviceByCredentialId([device1, device2], 'cred-unknown'),
62+
(err: Error) => {
63+
assert.strictEqual(err.message, 'Could not identify which passkey device was used');
64+
return true;
65+
}
66+
);
67+
});
68+
69+
it('throws when the device list is empty', function () {
70+
assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error);
71+
});
72+
});

0 commit comments

Comments
 (0)