Skip to content

Commit 94da3fc

Browse files
authored
Merge pull request #8663 from BitGo/WCN-187-passkey-types-v2
feat(passkey-crypto): extend package with PRF helpers and sdk-core passkey module
2 parents c4a3039 + 942bd84 commit 94da3fc

7 files changed

Lines changed: 148 additions & 0 deletions

File tree

modules/passkey-crypto/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"access": "public"
3535
},
3636
"dependencies": {
37+
"@bitgo/public-types": "6.1.0",
3738
"@bitgo/sjcl": "^1.1.0"
3839
},
3940
"devDependencies": {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { derivePassword } from './derivePassword';
22
export { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
3+
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
4+
export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './webAuthnTypes';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { WebauthnDevice } from '@bitgo/public-types';
2+
3+
/**
4+
* Builds the PRF eval map and credential-to-device lookup from a wallet
5+
* keychain's webauthn devices. Devices without a prfSalt are skipped.
6+
*/
7+
export function buildEvalByCredential(devices: WebauthnDevice[]): {
8+
evalByCredential: Record<string, string>;
9+
credIdToDevice: Map<string, WebauthnDevice>;
10+
} {
11+
const evalByCredential: Record<string, string> = {};
12+
const credIdToDevice = new Map<string, WebauthnDevice>();
13+
14+
for (const device of devices) {
15+
if (!device.prfSalt) continue;
16+
const { credID } = device.authenticatorInfo;
17+
evalByCredential[credID] = device.prfSalt;
18+
credIdToDevice.set(credID, device);
19+
}
20+
21+
return { evalByCredential, credIdToDevice };
22+
}
23+
24+
/**
25+
* Returns the WebauthnDevice matching the given credential ID.
26+
* @throws if no matching device is found
27+
*/
28+
export function matchDeviceByCredentialId(devices: WebauthnDevice[], credentialId: string): WebauthnDevice {
29+
const device = devices.find((d) => d.authenticatorInfo.credID === credentialId);
30+
if (!device) {
31+
throw new Error('Could not identify which passkey device was used');
32+
}
33+
return device;
34+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export type { WebAuthnOtpDevice } from '@bitgo/public-types';
2+
3+
/** Result of a WebAuthn assertion with the PRF extension. */
4+
export interface PasskeyAuthResult {
5+
prfResult: ArrayBuffer | undefined;
6+
credentialId: string;
7+
otpCode: string;
8+
}
9+
10+
/** Options for WebAuthnProvider.get(). */
11+
export interface PasskeyGetOptions {
12+
publicKey: PublicKeyCredentialRequestOptions;
13+
evalByCredential?: Record<string, string>;
14+
}
15+
16+
/** Abstraction over the WebAuthn credential API. */
17+
export interface WebAuthnProvider {
18+
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential>;
19+
get(options: PasskeyGetOptions): Promise<PasskeyAuthResult>;
20+
}
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';
3+
import { WebauthnDevice } from '@bitgo/public-types';
4+
5+
const device1: WebauthnDevice = {
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: WebauthnDevice = {
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, { 'cred-aaa': 'salt-aaa', 'cred-bbb': 'salt-bbb' });
23+
});
24+
25+
it('populates credIdToDevice with both devices', function () {
26+
const { credIdToDevice } = buildEvalByCredential([device1, device2]);
27+
assert.strictEqual(credIdToDevice.get('cred-aaa'), device1);
28+
assert.strictEqual(credIdToDevice.get('cred-bbb'), device2);
29+
});
30+
31+
it('returns empty maps for an empty device list', function () {
32+
const { evalByCredential, credIdToDevice } = buildEvalByCredential([]);
33+
assert.deepStrictEqual(evalByCredential, {});
34+
assert.strictEqual(credIdToDevice.size, 0);
35+
});
36+
37+
it('skips devices with empty prfSalt', function () {
38+
const deviceNoPrf = { ...device1, prfSalt: '' };
39+
const { evalByCredential, credIdToDevice } = buildEvalByCredential([deviceNoPrf, device2]);
40+
assert.deepStrictEqual(evalByCredential, { 'cred-bbb': 'salt-bbb' });
41+
assert.strictEqual(credIdToDevice.has('cred-aaa'), false);
42+
});
43+
44+
it('skips devices with undefined prfSalt', function () {
45+
const deviceNoPrf = { ...device1, prfSalt: undefined as unknown as string };
46+
const { evalByCredential, credIdToDevice } = buildEvalByCredential([deviceNoPrf, device2]);
47+
assert.deepStrictEqual(evalByCredential, { 'cred-bbb': 'salt-bbb' });
48+
assert.strictEqual(credIdToDevice.has('cred-aaa'), false);
49+
});
50+
});
51+
52+
describe('matchDeviceByCredentialId', function () {
53+
it('returns the matching device', function () {
54+
assert.strictEqual(matchDeviceByCredentialId([device1, device2], 'cred-bbb'), device2);
55+
});
56+
57+
it('returns the first device when it matches', function () {
58+
assert.strictEqual(matchDeviceByCredentialId([device1, device2], 'cred-aaa'), device1);
59+
});
60+
61+
it('returns a device even when it has no prfSalt', function () {
62+
const deviceNoPrf = { ...device1, prfSalt: '' };
63+
assert.strictEqual(matchDeviceByCredentialId([deviceNoPrf, device2], 'cred-aaa'), deviceNoPrf);
64+
});
65+
66+
it('throws the expected 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+
});

modules/passkey-crypto/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"extends": "../../tsconfig.json",
33
"compilerOptions": {
4+
"lib": ["ES2020", "DOM"],
45
"outDir": "./dist",
56
"rootDir": "./",
67
"strictPropertyInitialization": false,

yarn.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,17 @@
10101010
monocle-ts "^2.3.13"
10111011
newtype-ts "^0.3.5"
10121012

1013+
"@bitgo/public-types@6.1.0":
1014+
version "6.1.0"
1015+
resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.1.0.tgz#7c3949a0ae4de706b3d6a748ab07669a330e3fad"
1016+
integrity sha512-k+3cYvcSzpaqBcBO3saZkwfsazE3JY9WC321WX76fAYFTt6v6Q71pyUSCH41dTEZz9KGi79DwicCnpKsREw8eg==
1017+
dependencies:
1018+
fp-ts "^2.0.0"
1019+
io-ts "npm:@bitgo-forks/io-ts@2.1.4"
1020+
io-ts-types "^0.5.16"
1021+
monocle-ts "^2.3.13"
1022+
newtype-ts "^0.3.5"
1023+
10131024
"@bitgo/wasm-dot@^1.7.0":
10141025
version "1.7.0"
10151026
resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.7.0.tgz"

0 commit comments

Comments
 (0)