Skip to content

Commit 587488a

Browse files
committed
feat(passkey-crypto): extend package with PRF helpers and sdk-core passkey module
- add buildEvalByCredential and matchDeviceByCredentialId to @bitgo/passkey-crypto - use WebauthnDevice from @bitgo/public-types to avoid circular deps - create sdk-core/passkey/ with WebAuthnProvider, PasskeyAuthResult, PasskeyGetOptions - re-export WebAuthnOtpDevice from @bitgo/public-types in sdk-core - alias KeychainWebauthnDevice to WebauthnDevice from @bitgo/public-types - bump @bitgo/public-types to 6.1.0 and add @bitgo/passkey-crypto to sdk-core TICKET: WCN-187
1 parent 41a9575 commit 587488a

9 files changed

Lines changed: 142 additions & 4 deletions

File tree

Dockerfile

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ COPY --from=builder /tmp/bitgo/modules/sdk-coin-icp /var/modules/sdk-coin-icp/
9696
COPY --from=builder /tmp/bitgo/modules/sdk-coin-initia /var/modules/sdk-coin-initia/
9797
COPY --from=builder /tmp/bitgo/modules/sdk-coin-injective /var/modules/sdk-coin-injective/
9898
COPY --from=builder /tmp/bitgo/modules/sdk-coin-islm /var/modules/sdk-coin-islm/
99-
COPY --from=builder /tmp/bitgo/modules/sdk-coin-kaspa /var/modules/sdk-coin-kaspa/
10099
COPY --from=builder /tmp/bitgo/modules/sdk-coin-mon /var/modules/sdk-coin-mon/
101100
COPY --from=builder /tmp/bitgo/modules/sdk-coin-near /var/modules/sdk-coin-near/
102101
COPY --from=builder /tmp/bitgo/modules/sdk-coin-oas /var/modules/sdk-coin-oas/
@@ -198,7 +197,6 @@ cd /var/modules/sdk-coin-icp && yarn link && \
198197
cd /var/modules/sdk-coin-initia && yarn link && \
199198
cd /var/modules/sdk-coin-injective && yarn link && \
200199
cd /var/modules/sdk-coin-islm && yarn link && \
201-
cd /var/modules/sdk-coin-kaspa && yarn link && \
202200
cd /var/modules/sdk-coin-mon && yarn link && \
203201
cd /var/modules/sdk-coin-near && yarn link && \
204202
cd /var/modules/sdk-coin-oas && yarn link && \
@@ -303,7 +301,6 @@ RUN cd /var/bitgo-express && \
303301
yarn link @bitgo/sdk-coin-initia && \
304302
yarn link @bitgo/sdk-coin-injective && \
305303
yarn link @bitgo/sdk-coin-islm && \
306-
yarn link @bitgo/sdk-coin-kaspa && \
307304
yarn link @bitgo/sdk-coin-mon && \
308305
yarn link @bitgo/sdk-coin-near && \
309306
yarn link @bitgo/sdk-coin-oas && \

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { derivePassword } from './derivePassword';
22
export { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
3+
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
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: 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/sdk-core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
]
4141
},
4242
"dependencies": {
43-
"@bitgo/public-types": "5.96.2",
43+
"@bitgo/passkey-crypto": "^0.1.0",
44+
"@bitgo/public-types": "6.1.0",
4445
"@bitgo/sdk-lib-mpc": "^10.11.1",
4546
"@bitgo/secp256k1": "^1.11.0",
4647
"@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
@@ -17,6 +17,7 @@ export * from './internal';
1717
export * from './keychain';
1818
export * as bitcoin from './legacyBitcoin';
1919
export * from './market';
20+
export * from './passkey';
2021
export * from './pendingApproval';
2122
export { WalletProofs } from './proofs';
2223
export * from './recovery';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './types';
2+
export { buildEvalByCredential, matchDeviceByCredentialId } from '@bitgo/passkey-crypto';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export type { WebAuthnOtpDevice } from '@bitgo/public-types';
2+
3+
/** Result of a WebAuthn assertion with the PRF extension. */
4+
export interface PasskeyAuthResult {
5+
// undefined if the authenticator does not support PRF
6+
prfResult: ArrayBuffer | undefined;
7+
credentialId: string;
8+
otpCode: string;
9+
}
10+
11+
/** Options for WebAuthnProvider.get(). */
12+
export interface PasskeyGetOptions {
13+
publicKey: PublicKeyCredentialRequestOptions;
14+
// PRF eval map: { [credentialId]: salt }
15+
evalByCredential?: Record<string, string>;
16+
}
17+
18+
/** Abstraction over the WebAuthn credential API. */
19+
export interface WebAuthnProvider {
20+
create(options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential>;
21+
get(options: PasskeyGetOptions): Promise<PasskeyAuthResult>;
22+
}

0 commit comments

Comments
 (0)