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..5d3fd68176 --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/index.ts @@ -0,0 +1,2 @@ +export { WebAuthnOtpDevice, PasskeyAuthResult, 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..a0117bd351 --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/prfHelpers.ts @@ -0,0 +1,38 @@ +import { KeychainWebauthnDevice } from '../keychain/iKeychains'; + +/** + * Builds the `evalByCredential` map passed to `WebAuthnProvider.get()`. + * Maps each device's credID to its prfSalt so the authenticator can + * evaluate the PRF with the correct salt for whichever credential it selects. + * + * Mirrors retail's buildEvalByCredentialFromKeychain — takes KeychainWebauthnDevice[] + * directly so credID is read from authenticatorInfo where it actually lives. + * + * @param devices - webauthnDevices from the wallet keychain + * @returns a map of { [credID]: prfSalt } + */ +export function buildEvalByCredential(devices: KeychainWebauthnDevice[]): Record { + return Object.fromEntries(devices.map((d) => [d.authenticatorInfo.credID, d.prfSalt])); +} + +/** + * Finds the KeychainWebauthnDevice whose credID matches the credential ID + * returned by the WebAuthn assertion. + * + * @param devices - webauthnDevices from the wallet keychain + * @param credentialId - base64url credential ID from the WebAuthn assertion + * @throws if no device matches + */ +export function matchDeviceByCredentialId( + devices: KeychainWebauthnDevice[], + credentialId: string +): KeychainWebauthnDevice { + const device = devices.find((d) => d.authenticatorInfo.credID === credentialId); + if (!device) { + throw new Error( + `No passkey device found matching credential ID "${credentialId}". ` + + `Known credential IDs: [${devices.map((d) => d.authenticatorInfo.credID).join(', ')}]` + ); + } + 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..b50b3dcfbb --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/types.ts @@ -0,0 +1,28 @@ +export interface WebAuthnOtpDevice { + /** MongoDB ObjectId — used for deletion API calls */ + otpDeviceId: string; + /** base64url WebAuthn credential ID */ + credID: string; + /** WebAuthn attestation format */ + fmt: 'none' | 'packed' | 'fido-u2f'; + /** Base64-encoded public key from the authenticator */ + publicKey: string; + /** base64url-encoded salt from the server — optional */ + prfSalt?: string; + /** SJCL-encrypted private key (present once passkey is attached to a wallet keychain) */ + encryptedPrv?: string; +} + +export interface PasskeyAuthResult { + /** Raw PRF output — undefined if the authenticator does not support PRF */ + prfResult: ArrayBuffer | undefined; + /** base64url credential ID returned by the authenticator — matches KeychainWebauthnDevice.authenticatorInfo.credID */ + credentialId: string; + /** JSON-stringified WebAuthn assertion — pass to sdk.unlock({ otp: otpCode }) */ + otpCode: string; +} + +export interface WebAuthnProvider { + create(options: PublicKeyCredentialCreationOptions): Promise; + get(options: PublicKeyCredentialRequestOptions): 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..4094fb1eea --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/passkey/prfHelpers.test.ts @@ -0,0 +1,64 @@ +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 authenticatorInfo.credID to its prfSalt', function () { + const result = buildEvalByCredential([device1, device2]); + assert.deepStrictEqual(result, { + 'cred-aaa': 'salt-aaa', + 'cred-bbb': 'salt-bbb', + }); + }); + + it('returns an empty object for an empty device list', function () { + assert.deepStrictEqual(buildEvalByCredential([]), {}); + }); + + it('returns a single-entry map for one device', function () { + const result = buildEvalByCredential([device1]); + assert.deepStrictEqual(result, { 'cred-aaa': 'salt-aaa' }); + }); +}); + +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 a descriptive error when no device matches', function () { + assert.throws( + () => matchDeviceByCredentialId([device1, device2], 'cred-unknown'), + (err: Error) => { + assert.ok(err.message.includes('cred-unknown')); + assert.ok(err.message.includes('cred-aaa')); + assert.ok(err.message.includes('cred-bbb')); + return true; + } + ); + }); + + it('throws when the device list is empty', function () { + assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error); + }); +});