diff --git a/modules/passkey-crypto/package.json b/modules/passkey-crypto/package.json index 68f7a13741..0010782ef2 100644 --- a/modules/passkey-crypto/package.json +++ b/modules/passkey-crypto/package.json @@ -34,6 +34,7 @@ "access": "public" }, "dependencies": { + "@bitgo/public-types": "6.1.0", "@bitgo/sjcl": "^1.1.0" }, "devDependencies": { diff --git a/modules/passkey-crypto/src/index.ts b/modules/passkey-crypto/src/index.ts index f8a6c23cc8..787f130bd7 100644 --- a/modules/passkey-crypto/src/index.ts +++ b/modules/passkey-crypto/src/index.ts @@ -1,2 +1,4 @@ export { derivePassword } from './derivePassword'; export { deriveEnterpriseSalt } from './deriveEnterpriseSalt'; +export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers'; +export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './webAuthnTypes'; diff --git a/modules/passkey-crypto/src/prfHelpers.ts b/modules/passkey-crypto/src/prfHelpers.ts new file mode 100644 index 0000000000..cd69282caa --- /dev/null +++ b/modules/passkey-crypto/src/prfHelpers.ts @@ -0,0 +1,34 @@ +import type { WebauthnDevice } from '@bitgo/public-types'; + +/** + * 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: WebauthnDevice[]): { + 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 WebauthnDevice matching the given credential ID. + * @throws if no matching device is found + */ +export function matchDeviceByCredentialId(devices: WebauthnDevice[], credentialId: string): WebauthnDevice { + const device = devices.find((d) => d.authenticatorInfo.credID === credentialId); + if (!device) { + throw new Error('Could not identify which passkey device was used'); + } + return device; +} diff --git a/modules/passkey-crypto/src/webAuthnTypes.ts b/modules/passkey-crypto/src/webAuthnTypes.ts new file mode 100644 index 0000000000..567d13768e --- /dev/null +++ b/modules/passkey-crypto/src/webAuthnTypes.ts @@ -0,0 +1,20 @@ +export type { WebAuthnOtpDevice } from '@bitgo/public-types'; + +/** Result of a WebAuthn assertion with the PRF extension. */ +export interface PasskeyAuthResult { + prfResult: ArrayBuffer | undefined; + credentialId: string; + otpCode: string; +} + +/** Options for WebAuthnProvider.get(). */ +export interface PasskeyGetOptions { + publicKey: PublicKeyCredentialRequestOptions; + evalByCredential?: Record; +} + +/** Abstraction over the WebAuthn credential API. */ +export interface WebAuthnProvider { + create(options: PublicKeyCredentialCreationOptions): Promise; + get(options: PasskeyGetOptions): Promise; +} diff --git a/modules/passkey-crypto/test/unit/prfHelpers.test.ts b/modules/passkey-crypto/test/unit/prfHelpers.test.ts new file mode 100644 index 0000000000..83029edc3c --- /dev/null +++ b/modules/passkey-crypto/test/unit/prfHelpers.test.ts @@ -0,0 +1,79 @@ +import * as assert from 'assert'; +import { buildEvalByCredential, matchDeviceByCredentialId } from '../../src'; +import { WebauthnDevice } from '@bitgo/public-types'; + +const device1: WebauthnDevice = { + otpDeviceId: 'oid-1', + authenticatorInfo: { credID: 'cred-aaa', fmt: 'none', publicKey: 'pk-1' }, + prfSalt: 'salt-aaa', + encryptedPrv: 'enc-prv-1', +}; + +const device2: WebauthnDevice = { + 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 () { + assert.strictEqual(matchDeviceByCredentialId([device1, device2], 'cred-bbb'), device2); + }); + + it('returns the first device when it matches', function () { + assert.strictEqual(matchDeviceByCredentialId([device1, device2], 'cred-aaa'), device1); + }); + + it('returns a device even when it has no prfSalt', function () { + const deviceNoPrf = { ...device1, prfSalt: '' }; + assert.strictEqual(matchDeviceByCredentialId([deviceNoPrf, device2], 'cred-aaa'), deviceNoPrf); + }); + + it('throws the expected 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); + }); +}); diff --git a/modules/passkey-crypto/tsconfig.json b/modules/passkey-crypto/tsconfig.json index 6c98fcc52c..5c6d1e6385 100644 --- a/modules/passkey-crypto/tsconfig.json +++ b/modules/passkey-crypto/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "lib": ["ES2020", "DOM"], "outDir": "./dist", "rootDir": "./", "strictPropertyInitialization": false, diff --git a/yarn.lock b/yarn.lock index b056889d48..5a9980ca68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1010,6 +1010,17 @@ monocle-ts "^2.3.13" newtype-ts "^0.3.5" +"@bitgo/public-types@6.1.0": + version "6.1.0" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.1.0.tgz#7c3949a0ae4de706b3d6a748ab07669a330e3fad" + integrity sha512-k+3cYvcSzpaqBcBO3saZkwfsazE3JY9WC321WX76fAYFTt6v6Q71pyUSCH41dTEZz9KGi79DwicCnpKsREw8eg== + dependencies: + fp-ts "^2.0.0" + io-ts "npm:@bitgo-forks/io-ts@2.1.4" + io-ts-types "^0.5.16" + monocle-ts "^2.3.13" + newtype-ts "^0.3.5" + "@bitgo/wasm-dot@^1.7.0": version "1.7.0" resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.7.0.tgz" @@ -1521,13 +1532,6 @@ resolved "https://registry.npmjs.org/@cosmjs/utils/-/utils-0.33.1.tgz" integrity sha512-UnLHDY6KMmC+UXf3Ufyh+onE19xzEXjT4VZ504Acmk4PXxqyvG4cCPprlKUFnGUX7f0z8Or9MAOHXBx41uHBcg== -"@cspotcode/source-map-support@^0.8.0": - version "0.8.1" - resolved "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" - integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== - dependencies: - "@jridgewell/trace-mapping" "0.3.9" - "@csstools/postcss-cascade-layers@^1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-1.1.1.tgz" @@ -3189,7 +3193,7 @@ "@jridgewell/sourcemap-codec" "^1.5.0" "@jridgewell/trace-mapping" "^0.3.24" -"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": +"@jridgewell/resolve-uri@^3.1.0": version "3.1.2" resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== @@ -3202,19 +3206,11 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": version "1.5.5" resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@0.3.9": - version "0.3.9" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" - integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": version "0.3.30" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz" @@ -5857,26 +5853,6 @@ resolved "https://registry.npmjs.org/@tronweb3/google-protobuf/-/google-protobuf-3.21.4.tgz" integrity sha512-joxgV4esCdyZ921AprMIG1T7HjkypquhbJ5qJti/priCBJhRE1z9GOxIEMvayxSVSRbMGIoJNE0Knrg3vpwM1w== -"@tsconfig/node10@^1.0.7": - version "1.0.12" - resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz#be57ceac1e4692b41be9de6be8c32a106636dba4" - integrity sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ== - -"@tsconfig/node12@^1.0.7": - version "1.0.11" - resolved "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" - integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== - -"@tsconfig/node14@^1.0.0": - version "1.0.3" - resolved "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" - integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== - -"@tsconfig/node16@^1.0.2": - version "1.0.4" - resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" - integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== - "@tufjs/canonical-json@1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-1.0.0.tgz" @@ -7066,13 +7042,6 @@ acorn-walk@^8.0.0, acorn-walk@^8.0.2: dependencies: acorn "^8.11.0" -acorn-walk@^8.1.1: - version "8.3.5" - resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz#8a6b8ca8fc5b34685af15dabb44118663c296496" - integrity sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw== - dependencies: - acorn "^8.11.0" - acorn@7.1.1: version "7.1.1" resolved "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz" @@ -7093,11 +7062,6 @@ acorn@^8.0.4, acorn@^8.1.0, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0: resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== -acorn@^8.4.1: - version "8.16.0" - resolved "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" - integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== - add-stream@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz" @@ -7299,11 +7263,6 @@ are-we-there-yet@^3.0.0: delegates "^1.0.0" readable-stream "^3.6.0" -arg@^4.1.0: - version "4.1.3" - resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" - integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== - argparse@^1.0.10, argparse@^1.0.7: version "1.0.10" resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz" @@ -9553,11 +9512,6 @@ create-hmac@^1.1.0, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" -create-require@^1.1.0: - version "1.1.1" - resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" - integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== - cross-env@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz" @@ -14881,11 +14835,6 @@ make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: dependencies: semver "^6.0.0" -make-error@^1.1.1: - version "1.3.6" - resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" - integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== - make-fetch-happen@15.0.2, make-fetch-happen@^15.0.0, make-fetch-happen@^15.0.2: version "15.0.2" resolved "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.2.tgz" @@ -15423,7 +15372,7 @@ mocha@10.6.0: yargs-parser "^20.2.9" yargs-unparser "^2.0.0" -mocha@^10.0.0, mocha@^10.2.0: +mocha@^10.2.0: version "10.8.2" resolved "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz" integrity sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg== @@ -20193,25 +20142,6 @@ ts-loader@^9.1.2: semver "^7.3.4" source-map "^0.7.4" -ts-node@^10.0.0: - version "10.9.2" - resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" - integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== - dependencies: - "@cspotcode/source-map-support" "^0.8.0" - "@tsconfig/node10" "^1.0.7" - "@tsconfig/node12" "^1.0.7" - "@tsconfig/node14" "^1.0.0" - "@tsconfig/node16" "^1.0.2" - acorn "^8.4.1" - acorn-walk "^8.1.1" - arg "^4.1.0" - create-require "^1.1.0" - diff "^4.0.1" - make-error "^1.1.1" - v8-compile-cache-lib "^3.0.1" - yn "3.1.1" - ts-results@^3.2.1: version "3.3.0" resolved "https://registry.npmjs.org/ts-results/-/ts-results-3.3.0.tgz" @@ -20509,11 +20439,6 @@ typescript@^4.2.4: resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -typescript@~5.4.5: - version "5.4.5" - resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" - integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== - "ua-parser-js@>0.7.30 <0.8.0", ua-parser-js@^0.7.30, ua-parser-js@^1.0.35: version "0.7.41" resolved "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz#9f6dee58c389e8afababa62a4a2dc22edb69a452" @@ -20816,11 +20741,6 @@ uuid@^8.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -v8-compile-cache-lib@^3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" - integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== - v8-compile-cache@^2.0.3: version "2.4.0" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz" @@ -21642,11 +21562,6 @@ yeoman-generator@^5.6.1: sort-keys "^4.2.0" text-table "^0.2.0" -yn@3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" - integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== - yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"