From 74f10a0134877413fa9a93a02b74910d0d3d93e8 Mon Sep 17 00:00:00 2001 From: Derran Wijesinghe Date: Wed, 6 May 2026 10:50:39 -0400 Subject: [PATCH] feat(sdk-core): add derivePasskeyPrfKey function - fetch keychain webauthn devices and build PRF eval map - fetch server-issued assertion challenge via bitgo - trigger WebAuthn assertion via provider (navigator layer) - derive hex wallet passphrase from PRF output Ticket: WCN-192 --- .../passkey-crypto/src/derivePasskeyPrfKey.ts | 61 ++++++++ .../test/unit/derivePasskeyPrfKey.test.ts | 147 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 modules/passkey-crypto/src/derivePasskeyPrfKey.ts create mode 100644 modules/passkey-crypto/test/unit/derivePasskeyPrfKey.test.ts diff --git a/modules/passkey-crypto/src/derivePasskeyPrfKey.ts b/modules/passkey-crypto/src/derivePasskeyPrfKey.ts new file mode 100644 index 0000000000..6d3580f3e0 --- /dev/null +++ b/modules/passkey-crypto/src/derivePasskeyPrfKey.ts @@ -0,0 +1,61 @@ +import type { BitGoBase, IWallet } from '@bitgo/sdk-core'; +import { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers'; +import { derivePassword } from './derivePassword'; +import type { WebAuthnProvider } from './webAuthnTypes'; + +interface AssertionChallengeResponse { + challenge: string; +} + +/** + * Derives a wallet passphrase from a passkey PRF output. + * + * Fetches the wallet's user keychain, triggers a WebAuthn assertion with PRF + * evaluation, and returns a hex-encoded passphrase suitable for use as + * walletPassphrase in signing calls. + */ +export async function derivePasskeyPrfKey(params: { + bitgo: BitGoBase; + wallet: IWallet; + provider: WebAuthnProvider; +}): Promise { + const { bitgo, wallet, provider } = params; + + // Fetch the wallet's user keychain to get webauthnDevices + const keychain = await wallet.getEncryptedUserKeychain(); + const devices = keychain.webauthnDevices; + + if (!devices || devices.length === 0) { + throw new Error('No passkey devices available'); + } + + // Build PRF eval map from devices + const { evalByCredential } = buildEvalByCredential(devices as Parameters[0]); + + if (Object.keys(evalByCredential).length === 0) { + throw new Error('No passkey devices available with a valid PRF salt'); + } + + // Fetch a server-issued assertion challenge + const { challenge } = (await bitgo + .get(bitgo.url('/user/otp/webauthn/assertion', 2)) + .result()) as AssertionChallengeResponse; + + // Trigger WebAuthn assertion with PRF evaluation via the provider (navigator layer) + const result = await provider.get({ + publicKey: { + challenge: Buffer.from(challenge, 'base64'), + } as PublicKeyCredentialRequestOptions, + evalByCredential, + }); + + // Verify the credential matches a known device + matchDeviceByCredentialId(devices as Parameters[0], result.credentialId); + + // Derive and return hex-encoded wallet passphrase + if (!result.prfResult) { + throw new Error('PRF output was not returned by the authenticator'); + } + + return derivePassword(result.prfResult); +} diff --git a/modules/passkey-crypto/test/unit/derivePasskeyPrfKey.test.ts b/modules/passkey-crypto/test/unit/derivePasskeyPrfKey.test.ts new file mode 100644 index 0000000000..4b16913fe2 --- /dev/null +++ b/modules/passkey-crypto/test/unit/derivePasskeyPrfKey.test.ts @@ -0,0 +1,147 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { derivePasskeyPrfKey } from '../../src/derivePasskeyPrfKey'; + +describe('derivePasskeyPrfKey', function () { + const mockDevices = [ + { + otpDeviceId: 'device-1', + authenticatorInfo: { credID: 'cred-aaa', fmt: 'none' as const, publicKey: 'pk-1' }, + prfSalt: 'salt-aaa', + encryptedPrv: 'enc-prv-1', + }, + { + otpDeviceId: 'device-2', + authenticatorInfo: { credID: 'cred-bbb', fmt: 'none' as const, publicKey: 'pk-2' }, + prfSalt: 'salt-bbb', + encryptedPrv: 'enc-prv-2', + }, + ]; + + function makeWallet(devices: typeof mockDevices | undefined) { + return { + getEncryptedUserKeychain: sinon.stub().resolves({ + id: 'keychain-id', + pub: 'xpub123', + encryptedPrv: 'encrypted-prv', + type: 'independent', + webauthnDevices: devices, + }), + }; + } + + function makeBitGo(challenge = 'dGVzdC1jaGFsbGVuZ2U=') { + return { + url: sinon.stub().callsFake((path: string) => `https://app.bitgo.com/api/v2${path}`), + get: sinon.stub().returns({ + result: sinon.stub().resolves({ challenge }), + }), + }; + } + + afterEach(function () { + sinon.restore(); + }); + + it('should return a hex string on happy path', async function () { + const prfResult = new Uint8Array([0xde, 0xad, 0xbe, 0xef]).buffer; + + const mockProvider = { + create: sinon.stub(), + get: sinon.stub().resolves({ + prfResult, + credentialId: 'cred-aaa', + otpCode: 'otp-123', + }), + }; + + const wallet = makeWallet(mockDevices); + const mockBitGo = makeBitGo(); + + const result = await derivePasskeyPrfKey({ + bitgo: mockBitGo as any, + wallet: wallet as any, + provider: mockProvider, + }); + + // derivePassword converts ArrayBuffer to hex + assert.strictEqual(result, 'deadbeef'); + assert.ok(mockProvider.get.calledOnce); + // Verify evalByCredential was passed + const getCallArgs = mockProvider.get.firstCall.args[0]; + assert.strictEqual(getCallArgs.evalByCredential['cred-aaa'], 'salt-aaa'); + assert.strictEqual(getCallArgs.evalByCredential['cred-bbb'], 'salt-bbb'); + // Verify bitgo was used to fetch the assertion challenge + assert.ok(mockBitGo.get.calledOnce); + assert.ok(mockBitGo.url.calledWith('/user/otp/webauthn/assertion', 2)); + }); + + it("should throw 'No passkey devices available' when no devices", async function () { + const wallet = makeWallet(undefined); + const mockProvider = { create: sinon.stub(), get: sinon.stub() }; + + await assert.rejects( + () => derivePasskeyPrfKey({ bitgo: makeBitGo() as any, wallet: wallet as any, provider: mockProvider }), + (err: Error) => { + assert.strictEqual(err.message, 'No passkey devices available'); + return true; + } + ); + }); + + it("should throw 'No passkey devices available' when devices array is empty", async function () { + const wallet = makeWallet([] as any); + const mockProvider = { create: sinon.stub(), get: sinon.stub() }; + + await assert.rejects( + () => derivePasskeyPrfKey({ bitgo: makeBitGo() as any, wallet: wallet as any, provider: mockProvider }), + (err: Error) => { + assert.strictEqual(err.message, 'No passkey devices available'); + return true; + } + ); + }); + + it("should throw 'No passkey devices available with a valid PRF salt' when no device has prfSalt", async function () { + const devicesWithoutSalt = [ + { + otpDeviceId: 'device-1', + authenticatorInfo: { credID: 'cred-aaa', fmt: 'none' as const, publicKey: 'pk-1' }, + prfSalt: '', // empty — buildEvalByCredential skips falsy prfSalt + encryptedPrv: 'enc-prv-1', + }, + ]; + + const wallet = makeWallet(devicesWithoutSalt as any); + const mockProvider = { create: sinon.stub(), get: sinon.stub() }; + + await assert.rejects( + () => derivePasskeyPrfKey({ bitgo: makeBitGo() as any, wallet: wallet as any, provider: mockProvider }), + (err: Error) => { + assert.strictEqual(err.message, 'No passkey devices available with a valid PRF salt'); + return true; + } + ); + }); + + it("should throw 'Could not identify which passkey device was used' when credentialId not found", async function () { + const mockProvider = { + create: sinon.stub(), + get: sinon.stub().resolves({ + prfResult: new ArrayBuffer(32), + credentialId: 'unknown-cred-id', + otpCode: 'otp-123', + }), + }; + + const wallet = makeWallet(mockDevices); + + await assert.rejects( + () => derivePasskeyPrfKey({ bitgo: makeBitGo() as any, wallet: wallet as any, provider: mockProvider }), + (err: Error) => { + assert.strictEqual(err.message, 'Could not identify which passkey device was used'); + return true; + } + ); + }); +});