diff --git a/modules/passkey-crypto/src/attachPasskeyToWallet.ts b/modules/passkey-crypto/src/attachPasskeyToWallet.ts index 575828f7b9..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'; @@ -37,8 +38,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 }); @@ -46,38 +49,30 @@ 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 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/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 caef1d40fe..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. @@ -6,11 +7,17 @@ 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'); + 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 cd69282caa..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 @@ -14,8 +15,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 = toBase64Url(credID); + + // 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 +41,9 @@ 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 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])); + }); + }); +}); 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}$/); }); });