diff --git a/Dockerfile b/Dockerfile index b0ab9be760..060f525737 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,9 +43,10 @@ COPY --from=builder /tmp/bitgo/modules/express /var/bitgo-express/ #COPY_START COPY --from=builder /tmp/bitgo/modules/abstract-lightning /var/modules/abstract-lightning/ COPY --from=builder /tmp/bitgo/modules/sdk-core /var/modules/sdk-core/ +COPY --from=builder /tmp/bitgo/modules/passkey-crypto /var/modules/passkey-crypto/ +COPY --from=builder /tmp/bitgo/modules/sjcl /var/modules/sjcl/ COPY --from=builder /tmp/bitgo/modules/sdk-lib-mpc /var/modules/sdk-lib-mpc/ COPY --from=builder /tmp/bitgo/modules/sdk-opensslbytes /var/modules/sdk-opensslbytes/ -COPY --from=builder /tmp/bitgo/modules/sjcl /var/modules/sjcl/ COPY --from=builder /tmp/bitgo/modules/secp256k1 /var/modules/secp256k1/ COPY --from=builder /tmp/bitgo/modules/statics /var/modules/statics/ COPY --from=builder /tmp/bitgo/modules/utxo-lib /var/modules/utxo-lib/ @@ -145,9 +146,10 @@ COPY --from=builder /tmp/bitgo/modules/sdk-coin-zec /var/modules/sdk-coin-zec/ RUN cd /var/modules/abstract-lightning && yarn link && \ cd /var/modules/sdk-core && yarn link && \ +cd /var/modules/passkey-crypto && yarn link && \ +cd /var/modules/sjcl && yarn link && \ cd /var/modules/sdk-lib-mpc && yarn link && \ cd /var/modules/sdk-opensslbytes && yarn link && \ -cd /var/modules/sjcl && yarn link && \ cd /var/modules/secp256k1 && yarn link && \ cd /var/modules/statics && yarn link && \ cd /var/modules/utxo-lib && yarn link && \ @@ -250,9 +252,10 @@ cd /var/modules/sdk-coin-zec && yarn link RUN cd /var/bitgo-express && \ yarn link @bitgo/abstract-lightning && \ yarn link @bitgo/sdk-core && \ + yarn link @bitgo/passkey-crypto && \ + yarn link @bitgo/sjcl && \ yarn link @bitgo/sdk-lib-mpc && \ yarn link @bitgo/sdk-opensslbytes && \ - yarn link @bitgo/sjcl && \ yarn link @bitgo/secp256k1 && \ yarn link @bitgo/statics && \ yarn link @bitgo/utxo-lib && \ 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..cba1536dac 100644 --- a/modules/passkey-crypto/src/index.ts +++ b/modules/passkey-crypto/src/index.ts @@ -1,2 +1,3 @@ export { derivePassword } from './derivePassword'; export { deriveEnterpriseSalt } from './deriveEnterpriseSalt'; +export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers'; 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/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/sdk-core/package.json b/modules/sdk-core/package.json index cd45a38ba1..7e8153fecf 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,8 @@ ] }, "dependencies": { - "@bitgo/public-types": "5.96.2", + "@bitgo/passkey-crypto": "^0.1.0", + "@bitgo/public-types": "6.1.0", "@bitgo/sdk-lib-mpc": "^10.11.1", "@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..2a07426af6 100644 --- a/modules/sdk-core/src/bitgo/index.ts +++ b/modules/sdk-core/src/bitgo/index.ts @@ -17,6 +17,7 @@ export * from './internal'; export * from './keychain'; export * as bitcoin from './legacyBitcoin'; export * from './market'; +export * from './passkey'; export * from './pendingApproval'; export { WalletProofs } from './proofs'; export * from './recovery'; 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..08cfa101cc --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/index.ts @@ -0,0 +1,2 @@ +export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './types'; +export { buildEvalByCredential, matchDeviceByCredentialId } from '@bitgo/passkey-crypto'; 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..c5518ff6a7 --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/types.ts @@ -0,0 +1,22 @@ +export type { WebAuthnOtpDevice } from '@bitgo/public-types'; + +/** Result of a WebAuthn assertion with the PRF extension. */ +export interface PasskeyAuthResult { + // undefined if the authenticator does not support PRF + prfResult: ArrayBuffer | undefined; + credentialId: string; + otpCode: string; +} + +/** Options for WebAuthnProvider.get(). */ +export interface PasskeyGetOptions { + publicKey: PublicKeyCredentialRequestOptions; + // PRF eval map: { [credentialId]: salt } + evalByCredential?: Record; +} + +/** Abstraction over the WebAuthn credential API. */ +export interface WebAuthnProvider { + create(options: PublicKeyCredentialCreationOptions): Promise; + get(options: PasskeyGetOptions): Promise; +}