diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 4bcc5e45a9..319fb9ff67 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "5.96.2", + "@bitgo/public-types": "6.1.0", "@bitgo/sdk-lib-mpc": "^10.11.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/sjcl": "^1.1.0", diff --git a/modules/sdk-core/src/bitgo/index.ts b/modules/sdk-core/src/bitgo/index.ts index c320eee454..df9ecb83c5 100644 --- a/modules/sdk-core/src/bitgo/index.ts +++ b/modules/sdk-core/src/bitgo/index.ts @@ -20,6 +20,7 @@ export * from './market'; export * from './pendingApproval'; export { WalletProofs } from './proofs'; export * from './recovery'; +export * from './passkey'; export * from './staking'; export * from './trading'; export * from './tss'; diff --git a/modules/sdk-core/src/bitgo/passkey/index.ts b/modules/sdk-core/src/bitgo/passkey/index.ts new file mode 100644 index 0000000000..f3c43fefdc --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/index.ts @@ -0,0 +1,2 @@ +export { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './types'; +export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers'; diff --git a/modules/sdk-core/src/bitgo/passkey/prfHelpers.ts b/modules/sdk-core/src/bitgo/passkey/prfHelpers.ts new file mode 100644 index 0000000000..f49c8f7b97 --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/prfHelpers.ts @@ -0,0 +1,39 @@ +import type { KeychainWebauthnDevice } from '../keychain/iKeychains'; + +/** + * Builds the PRF eval map and credential-to-device lookup from a wallet keychain's webauthn devices. + * Devices without a prfSalt are skipped. + */ +export function buildEvalByCredential(devices: KeychainWebauthnDevice[]): { + evalByCredential: Record; + credIdToDevice: Map; +} { + const evalByCredential: Record = {}; + const credIdToDevice = new Map(); + + for (const device of devices) { + if (!device.prfSalt) continue; + + const { credID } = device.authenticatorInfo; + evalByCredential[credID] = device.prfSalt; + credIdToDevice.set(credID, device); + } + + return { evalByCredential, credIdToDevice }; +} + +/** + * Returns the webauthn device matching the given credential ID. + * @throws if no matching device is found + */ +export function matchDeviceByCredentialId( + devices: KeychainWebauthnDevice[], + credentialId: string +): KeychainWebauthnDevice { + const { credIdToDevice } = buildEvalByCredential(devices); + const device = credIdToDevice.get(credentialId); + if (!device) { + throw new Error('Could not identify which passkey device was used'); + } + return device; +} diff --git a/modules/sdk-core/src/bitgo/passkey/types.ts b/modules/sdk-core/src/bitgo/passkey/types.ts new file mode 100644 index 0000000000..3c58d40cfd --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/types.ts @@ -0,0 +1,24 @@ +export type { WebAuthnOtpDevice } from '@bitgo/public-types'; + +/** Result of a WebAuthn assertion with the PRF extension. */ +export interface PasskeyAuthResult { + // raw PRF output; undefined if the authenticator does not support PRF + prfResult: ArrayBuffer | undefined; + // base64url credential ID identifying which passkey was used + credentialId: string; + // OTP code from the assertion + otpCode: string; +} + +/** Options for WebAuthnProvider.get(). */ +export interface PasskeyGetOptions { + publicKey: PublicKeyCredentialRequestOptions; + // PRF eval map: { [credentialId]: prfSalt } + evalByCredential?: Record; +} + +/** Abstraction over the WebAuthn credential API. Inject a mock in tests. */ +export interface WebAuthnProvider { + create(options: PublicKeyCredentialCreationOptions): Promise; + get(options: PasskeyGetOptions): Promise; +} diff --git a/modules/sdk-core/test/unit/bitgo/passkey/prfHelpers.test.ts b/modules/sdk-core/test/unit/bitgo/passkey/prfHelpers.test.ts new file mode 100644 index 0000000000..702ce836e1 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/passkey/prfHelpers.test.ts @@ -0,0 +1,79 @@ +import * as assert from 'assert'; +import { buildEvalByCredential, matchDeviceByCredentialId } from '../../../../src/bitgo/passkey/prfHelpers'; +import { KeychainWebauthnDevice } from '../../../../src/bitgo/keychain/iKeychains'; + +const device1: KeychainWebauthnDevice = { + otpDeviceId: 'oid-1', + authenticatorInfo: { credID: 'cred-aaa', fmt: 'none', publicKey: 'pk-1' }, + prfSalt: 'salt-aaa', + encryptedPrv: 'enc-prv-1', +}; + +const device2: KeychainWebauthnDevice = { + otpDeviceId: 'oid-2', + authenticatorInfo: { credID: 'cred-bbb', fmt: 'none', publicKey: 'pk-2' }, + prfSalt: 'salt-bbb', + encryptedPrv: 'enc-prv-2', +}; + +describe('buildEvalByCredential', function () { + it('maps each device credID to its prfSalt in evalByCredential', function () { + const { evalByCredential } = buildEvalByCredential([device1, device2]); + assert.deepStrictEqual(evalByCredential, { + 'cred-aaa': 'salt-aaa', + 'cred-bbb': 'salt-bbb', + }); + }); + + it('populates credIdToDevice with both devices', function () { + const { credIdToDevice } = buildEvalByCredential([device1, device2]); + assert.strictEqual(credIdToDevice.get('cred-aaa'), device1); + assert.strictEqual(credIdToDevice.get('cred-bbb'), device2); + }); + + it('returns empty maps for an empty device list', function () { + const { evalByCredential, credIdToDevice } = buildEvalByCredential([]); + assert.deepStrictEqual(evalByCredential, {}); + assert.strictEqual(credIdToDevice.size, 0); + }); + + it('skips devices with empty prfSalt', function () { + const deviceNoPrf = { ...device1, prfSalt: '' }; + const { evalByCredential, credIdToDevice } = buildEvalByCredential([deviceNoPrf, device2]); + assert.deepStrictEqual(evalByCredential, { 'cred-bbb': 'salt-bbb' }); + assert.strictEqual(credIdToDevice.has('cred-aaa'), false); + }); + + it('skips devices with undefined prfSalt', function () { + const deviceNoPrf = { ...device1, prfSalt: undefined as unknown as string }; + const { evalByCredential, credIdToDevice } = buildEvalByCredential([deviceNoPrf, device2]); + assert.deepStrictEqual(evalByCredential, { 'cred-bbb': 'salt-bbb' }); + assert.strictEqual(credIdToDevice.has('cred-aaa'), false); + }); +}); + +describe('matchDeviceByCredentialId', function () { + it('returns the matching device', function () { + const result = matchDeviceByCredentialId([device1, device2], 'cred-bbb'); + assert.strictEqual(result, device2); + }); + + it('returns the first device when it matches', function () { + const result = matchDeviceByCredentialId([device1, device2], 'cred-aaa'); + assert.strictEqual(result, device1); + }); + + it('throws with the retail error message when no device matches', function () { + assert.throws( + () => matchDeviceByCredentialId([device1, device2], 'cred-unknown'), + (err: Error) => { + assert.strictEqual(err.message, 'Could not identify which passkey device was used'); + return true; + } + ); + }); + + it('throws when the device list is empty', function () { + assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error); + }); +});