From d263f3781ace71b42e7950dd0e40738b9e040961 Mon Sep 17 00:00:00 2001 From: Derran Wijesinghe Date: Thu, 7 May 2026 19:49:52 -0400 Subject: [PATCH 1/2] fix(passkey-crypto): use base64url everywhere for the PRF salt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attach and derive flows were feeding different bytes to the WebAuthn PRF extension for the same passkey, so the password derived at attach time could not decrypt the keychain blob written with that same password (ccm tag mismatch on every transaction approval). Three encoding inconsistencies caused this: - deriveEnterpriseSalt returned hex while the server stores base64url and the WebAuthn extension expects raw bytes — every consumer had to re-encode, and one of them did it wrong. - attachPasskeyToWallet ran the hex output back through a hex-to-base64url conversion before handing it to provider.get, so the browser PRF saw the hex characters interpreted as base64 garbage. - prfHelpers.buildEvalByCredential tried to convert the stored base64url salt to hex via Buffer.from(...).toString('hex'), which is a no-op under the browser Buffer polyfill and a real conversion in Node — same code, different bytes. Standardise on base64url end-to-end: deriveEnterpriseSalt emits base64url, attachPasskeyToWallet passes that string straight through to the PRF eval, and prfHelpers reads device.prfSalt unchanged. The WebAuthn provider layer is the single point that decodes base64url to bytes for navigator.credentials.get. Refs: WCN-410 TICKET: WCN-410 --- .../src/attachPasskeyToWallet.ts | 24 +++++++------------ .../src/deriveEnterpriseSalt.ts | 15 ++++++++++-- modules/passkey-crypto/src/prfHelpers.ts | 23 +++++++++++++++--- .../test/unit/deriveEnterpriseSalt.test.ts | 10 +++++--- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/modules/passkey-crypto/src/attachPasskeyToWallet.ts b/modules/passkey-crypto/src/attachPasskeyToWallet.ts index 575828f7b9..933bfbe988 100644 --- a/modules/passkey-crypto/src/attachPasskeyToWallet.ts +++ b/modules/passkey-crypto/src/attachPasskeyToWallet.ts @@ -37,8 +37,10 @@ export async function attachPasskeyToWallet(params: { const keychain = await wallet.getEncryptedUserKeychain(); const keychainId = keychain.id; - // Derive enterprise-scoped salt - const enterpriseSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId); + // Derive enterprise-scoped salt (base64url; same encoding is used as the + // PRF eval input and as the server-stored prfSalt so the bytes fed to the + // authenticator match between attach and derive). + const prfSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId); // Decrypt private key with existing passphrase const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv }); @@ -48,36 +50,28 @@ export async function attachPasskeyToWallet(params: { // and each entry must correspond to a key in the evalByCredential map. const credentialIdBuffer = Buffer.from(device.credentialId.replace(/-/g, '+').replace(/_/g, '/'), 'base64').buffer; - // PRF assertion — evalByCredential maps this device's credentialId to its enterprise salt + // PRF assertion — evalByCredential maps this device's credentialId to the + // base64url enterprise salt. The provider layer is responsible for decoding + // base64url to raw bytes before handing it to the WebAuthn PRF extension. const authResult = await provider.get({ publicKey: { allowCredentials: [{ type: 'public-key', id: credentialIdBuffer }], } as PublicKeyCredentialRequestOptions, - evalByCredential: { [device.credentialId]: enterpriseSalt }, + evalByCredential: { [device.credentialId]: prfSalt }, }); if (!authResult.prfResult) { throw new Error('PRF assertion did not return a result.'); } - // Derive password from PRF output and re-encrypt const prfPassword = derivePassword(authResult.prfResult); const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey }); - // Convert enterpriseSalt from hex to base64url (URL-safe, no padding) - // as required by the server's prfSalt validation. - const prfSaltBase64url = Buffer.from(enterpriseSalt, 'hex') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - // PUT webauthnInfo to keychain endpoint const updatedKeychain = await bitgo .put(bitgo.url(`/${coin}/key/${keychainId}`, 2)) .send({ webauthnInfo: { - prfSalt: prfSaltBase64url, + prfSalt, otpDeviceId: device.id, encryptedPrv, }, diff --git a/modules/passkey-crypto/src/deriveEnterpriseSalt.ts b/modules/passkey-crypto/src/deriveEnterpriseSalt.ts index caef1d40fe..621253a686 100644 --- a/modules/passkey-crypto/src/deriveEnterpriseSalt.ts +++ b/modules/passkey-crypto/src/deriveEnterpriseSalt.ts @@ -6,11 +6,22 @@ import { createHmac } from 'crypto'; * Computes HMAC-SHA256(key=prfSalt_base64url_decoded, data=enterpriseId_utf8). * The baseSalt must always come from the server — never generate it client-side. * + * Returns base64url so the same encoding is used everywhere the salt is handled + * (server storage, PRF eval input, prfHelpers lookup). Mixing encodings + * (e.g. hex on the client, base64url on the server) caused the PRF to receive + * different bytes during attach vs derive in browser environments where + * `Buffer.toString('hex')` is unreliable. + * * @param baseSalt - Server-provided base64url-encoded PRF salt * @param enterpriseId - Enterprise identifier - * @returns Hex-encoded HMAC-SHA256 digest + * @returns Base64url-encoded HMAC-SHA256 digest (no padding) */ export function deriveEnterpriseSalt(baseSalt: string, enterpriseId: string): string { const keyBytes = Buffer.from(baseSalt.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); - return createHmac('sha256', keyBytes).update(enterpriseId).digest('hex'); + return createHmac('sha256', keyBytes) + .update(enterpriseId) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); } diff --git a/modules/passkey-crypto/src/prfHelpers.ts b/modules/passkey-crypto/src/prfHelpers.ts index cd69282caa..22e8ad4f73 100644 --- a/modules/passkey-crypto/src/prfHelpers.ts +++ b/modules/passkey-crypto/src/prfHelpers.ts @@ -14,8 +14,22 @@ export function buildEvalByCredential(devices: WebauthnDevice[]): { for (const device of devices) { if (!device.prfSalt) continue; const { credID } = device.authenticatorInfo; - evalByCredential[credID] = device.prfSalt; - credIdToDevice.set(credID, device); + + // Normalise credID to base64url (no padding, URL-safe chars) so it matches + // the key format used by attachPasskeyToWallet (device.credentialId from the + // browser, which is already base64url). The WebAuthn PRF extension looks up + // the selected credential's ID against evalByCredential keys — if the encoding + // differs (e.g. standard base64 with padding/+/), the lookup silently fails and + // PRF evaluates with no salt, producing a different output. + const credIdBase64url = credID.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + // Pass prfSalt through as-is (base64url). attachPasskeyToWallet writes the + // server-stored salt in the same encoding and feeds the same string to + // the PRF extension at attach time, so both paths produce the same salt + // bytes — provided the WebAuthn provider layer decodes base64url before + // handing the bytes to navigator.credentials.get. + evalByCredential[credIdBase64url] = device.prfSalt; + credIdToDevice.set(credIdBase64url, device); } return { evalByCredential, credIdToDevice }; @@ -26,7 +40,10 @@ export function buildEvalByCredential(devices: WebauthnDevice[]): { * @throws if no matching device is found */ export function matchDeviceByCredentialId(devices: WebauthnDevice[], credentialId: string): WebauthnDevice { - const device = devices.find((d) => d.authenticatorInfo.credID === credentialId); + // Normalise both sides to base64url so padding/char differences don't break matching. + const normalise = (s: string) => s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const needle = normalise(credentialId); + const device = devices.find((d) => normalise(d.authenticatorInfo.credID) === needle); if (!device) { throw new Error('Could not identify which passkey device was used'); } diff --git a/modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts b/modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts index d9eaab111f..4bea5e7174 100644 --- a/modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts +++ b/modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts @@ -5,7 +5,10 @@ import { deriveEnterpriseSalt } from '../../src'; const REAL_FIXTURE = { basePrfSalt: 'ZqJ64M2dL65zn2-Jxd58SMN2ILc9QjbCFxUTGHd_LC8', enterpriseId: '69c2aea1a3d7bc07f7f775c0ca86b0ec', - expectedDerivedSalt: 'a226ac3aace4bb2b84cfff34e37fb7217620852bb72d5e0dfdad4c2c8473994f', + // base64url encoding of the HMAC-SHA256(baseSalt_decoded, enterpriseId) digest. + // Same encoding the server stores and the WebAuthn PRF extension consumes — keeping + // one encoding everywhere avoids the hex/base64url mismatch that broke browser PRF. + expectedDerivedSalt: 'oiasOqzkuyuEz_8043-3IXYghSu3LV4N_a1MLIRzmU8', }; describe('deriveEnterpriseSalt', function () { @@ -37,10 +40,11 @@ describe('deriveEnterpriseSalt', function () { assert.notStrictEqual(saltA, saltB); }); - it('returns a non-empty hex string', function () { + it('returns a non-empty unpadded base64url string', function () { const result = deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, REAL_FIXTURE.enterpriseId); assert.strictEqual(typeof result, 'string'); assert.ok(result.length > 0); - assert.match(result, /^[0-9a-f]{64}$/); + // Base64url alphabet, no padding. SHA-256 = 32 bytes → 43 base64url chars. + assert.match(result, /^[A-Za-z0-9_-]{43}$/); }); }); From 715c955a309605c93d024e19e5f5d0eb7e9955cb Mon Sep 17 00:00:00 2001 From: Derran Wijesinghe Date: Fri, 8 May 2026 00:52:27 -0400 Subject: [PATCH 2/2] refactor(passkey-crypto): extract base64url helpers Replace the duplicated `.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')` pattern (and its inverse) with a small `base64url` utility module shared by deriveEnterpriseSalt, prfHelpers, registerPasskey, and attachPasskeyToWallet. Addresses PR review feedback to deduplicate the encoding logic. TICKET: WCN-410 --- .../src/attachPasskeyToWallet.ts | 3 +- modules/passkey-crypto/src/base64url.ts | 24 ++++++++++ .../src/deriveEnterpriseSalt.ts | 10 ++-- modules/passkey-crypto/src/prfHelpers.ts | 8 ++-- modules/passkey-crypto/src/registerPasskey.ts | 10 ++-- .../test/unit/base64url.test.ts | 46 +++++++++++++++++++ 6 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 modules/passkey-crypto/src/base64url.ts create mode 100644 modules/passkey-crypto/test/unit/base64url.test.ts diff --git a/modules/passkey-crypto/src/attachPasskeyToWallet.ts b/modules/passkey-crypto/src/attachPasskeyToWallet.ts index 933bfbe988..ce925e78e6 100644 --- a/modules/passkey-crypto/src/attachPasskeyToWallet.ts +++ b/modules/passkey-crypto/src/attachPasskeyToWallet.ts @@ -1,4 +1,5 @@ import { BitGoBase, Keychain } from '@bitgo/sdk-core'; +import { base64UrlToBuffer } from './base64url'; import { deriveEnterpriseSalt } from './deriveEnterpriseSalt'; import { derivePassword } from './derivePassword'; import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes'; @@ -48,7 +49,7 @@ export async function attachPasskeyToWallet(params: { // Decode credentialId from base64url to ArrayBuffer for allowCredentials. // The WebAuthn spec requires allowCredentials to be non-empty when using evalByCredential, // and each entry must correspond to a key in the evalByCredential map. - const credentialIdBuffer = Buffer.from(device.credentialId.replace(/-/g, '+').replace(/_/g, '/'), 'base64').buffer; + const credentialIdBuffer = base64UrlToBuffer(device.credentialId).buffer; // PRF assertion — evalByCredential maps this device's credentialId to the // base64url enterprise salt. The provider layer is responsible for decoding diff --git a/modules/passkey-crypto/src/base64url.ts b/modules/passkey-crypto/src/base64url.ts new file mode 100644 index 0000000000..65f718c6dd --- /dev/null +++ b/modules/passkey-crypto/src/base64url.ts @@ -0,0 +1,24 @@ +/** + * Base64url encoding helpers. + * + * Base64url uses the same alphabet as standard base64 except `+` becomes `-`, + * `/` becomes `_`, and padding (`=`) is stripped. Browser WebAuthn APIs and + * the BitGo server both use base64url for credential IDs and PRF salts, so we + * normalise to it everywhere on the client to avoid mismatches caused by + * mixing encodings. + */ + +/** Converts a standard base64 string (or already-base64url string) to base64url. */ +export function toBase64Url(s: string): string { + return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Encodes an ArrayBuffer or Buffer as a base64url string (no padding). */ +export function bufferToBase64Url(buffer: ArrayBuffer | Buffer): string { + return toBase64Url(Buffer.from(buffer as ArrayBuffer).toString('base64')); +} + +/** Decodes a base64url string into a Buffer. */ +export function base64UrlToBuffer(s: string): Buffer { + return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); +} diff --git a/modules/passkey-crypto/src/deriveEnterpriseSalt.ts b/modules/passkey-crypto/src/deriveEnterpriseSalt.ts index 621253a686..54d3e5aa75 100644 --- a/modules/passkey-crypto/src/deriveEnterpriseSalt.ts +++ b/modules/passkey-crypto/src/deriveEnterpriseSalt.ts @@ -1,4 +1,5 @@ import { createHmac } from 'crypto'; +import { base64UrlToBuffer, toBase64Url } from './base64url'; /** * Derives an enterprise-scoped PRF salt to prevent cross-enterprise key reuse. @@ -17,11 +18,6 @@ import { createHmac } from 'crypto'; * @returns Base64url-encoded HMAC-SHA256 digest (no padding) */ export function deriveEnterpriseSalt(baseSalt: string, enterpriseId: string): string { - const keyBytes = Buffer.from(baseSalt.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); - return createHmac('sha256', keyBytes) - .update(enterpriseId) - .digest('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + const keyBytes = base64UrlToBuffer(baseSalt); + return toBase64Url(createHmac('sha256', keyBytes).update(enterpriseId).digest('base64')); } diff --git a/modules/passkey-crypto/src/prfHelpers.ts b/modules/passkey-crypto/src/prfHelpers.ts index 22e8ad4f73..964560bffb 100644 --- a/modules/passkey-crypto/src/prfHelpers.ts +++ b/modules/passkey-crypto/src/prfHelpers.ts @@ -1,4 +1,5 @@ import type { WebauthnDevice } from '@bitgo/public-types'; +import { toBase64Url } from './base64url'; /** * Builds the PRF eval map and credential-to-device lookup from a wallet @@ -21,7 +22,7 @@ export function buildEvalByCredential(devices: WebauthnDevice[]): { // the selected credential's ID against evalByCredential keys — if the encoding // differs (e.g. standard base64 with padding/+/), the lookup silently fails and // PRF evaluates with no salt, producing a different output. - const credIdBase64url = credID.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const credIdBase64url = toBase64Url(credID); // Pass prfSalt through as-is (base64url). attachPasskeyToWallet writes the // server-stored salt in the same encoding and feeds the same string to @@ -41,9 +42,8 @@ export function buildEvalByCredential(devices: WebauthnDevice[]): { */ export function matchDeviceByCredentialId(devices: WebauthnDevice[], credentialId: string): WebauthnDevice { // Normalise both sides to base64url so padding/char differences don't break matching. - const normalise = (s: string) => s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - const needle = normalise(credentialId); - const device = devices.find((d) => normalise(d.authenticatorInfo.credID) === needle); + const needle = toBase64Url(credentialId); + const device = devices.find((d) => toBase64Url(d.authenticatorInfo.credID) === needle); if (!device) { throw new Error('Could not identify which passkey device was used'); } diff --git a/modules/passkey-crypto/src/registerPasskey.ts b/modules/passkey-crypto/src/registerPasskey.ts index 71c96fb290..749352cfb4 100644 --- a/modules/passkey-crypto/src/registerPasskey.ts +++ b/modules/passkey-crypto/src/registerPasskey.ts @@ -1,4 +1,5 @@ import { BitGoBase } from '@bitgo/sdk-core'; +import { bufferToBase64Url } from './base64url'; import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes'; interface RegisterChallengeResponse { @@ -28,11 +29,6 @@ interface RegisterOtpResponse { }; } -/** Encodes an ArrayBuffer as a base64url string (no padding). */ -function encodeBase64Url(buffer: ArrayBuffer): string { - return Buffer.from(buffer).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); -} - /** * Recursively converts a PublicKeyCredential (or any value it contains) to a * JSON-serialisable representation, encoding ArrayBuffers as base64url strings. @@ -42,10 +38,10 @@ function publicKeyCredentialToJSON(value: unknown): unknown { return value.map(publicKeyCredentialToJSON); } if (value instanceof ArrayBuffer) { - return encodeBase64Url(value); + return bufferToBase64Url(value); } if (ArrayBuffer.isView(value)) { - return encodeBase64Url(value.buffer as ArrayBuffer); + return bufferToBase64Url(value.buffer as ArrayBuffer); } if (value instanceof Object) { const result: Record = {}; diff --git a/modules/passkey-crypto/test/unit/base64url.test.ts b/modules/passkey-crypto/test/unit/base64url.test.ts new file mode 100644 index 0000000000..f19822e214 --- /dev/null +++ b/modules/passkey-crypto/test/unit/base64url.test.ts @@ -0,0 +1,46 @@ +import * as assert from 'assert'; +import { base64UrlToBuffer, bufferToBase64Url, toBase64Url } from '../../src/base64url'; + +describe('base64url helpers', function () { + describe('toBase64Url', function () { + it('replaces +, / and strips padding', function () { + assert.strictEqual(toBase64Url('a+b/c=='), 'a-b_c'); + }); + + it('is a no-op on already-base64url input', function () { + assert.strictEqual(toBase64Url('a-b_c'), 'a-b_c'); + }); + + it('handles empty string', function () { + assert.strictEqual(toBase64Url(''), ''); + }); + }); + + describe('bufferToBase64Url', function () { + it('encodes a Buffer to unpadded base64url', function () { + // bytes that produce + and / in standard base64 + const buf = Buffer.from([0xfb, 0xff, 0xbf]); + assert.strictEqual(buf.toString('base64'), '+/+/'); + assert.strictEqual(bufferToBase64Url(buf), '-_-_'); + }); + + it('encodes an ArrayBuffer to unpadded base64url', function () { + const ab = new Uint8Array([0xff, 0xfe, 0xfd]).buffer; + assert.strictEqual(bufferToBase64Url(ab), '__79'); + }); + }); + + describe('base64UrlToBuffer', function () { + it('round-trips through bufferToBase64Url', function () { + const original = Buffer.from([0x00, 0xff, 0x10, 0x20, 0xab, 0xcd]); + const encoded = bufferToBase64Url(original); + const decoded = base64UrlToBuffer(encoded); + assert.deepStrictEqual(decoded, original); + }); + + it('decodes base64url with - and _ chars', function () { + const decoded = base64UrlToBuffer('-_-_'); + assert.deepStrictEqual(decoded, Buffer.from([0xfb, 0xff, 0xbf])); + }); + }); +});