Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 11 additions & 16 deletions modules/passkey-crypto/src/attachPasskeyToWallet.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,47 +38,41 @@ 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 });

// 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,
},
Expand Down
24 changes: 24 additions & 0 deletions modules/passkey-crypto/src/base64url.ts
Original file line number Diff line number Diff line change
@@ -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');
}
13 changes: 10 additions & 3 deletions modules/passkey-crypto/src/deriveEnterpriseSalt.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { createHmac } from 'crypto';
import { base64UrlToBuffer, toBase64Url } from './base64url';

/**
* Derives an enterprise-scoped PRF salt to prevent cross-enterprise key reuse.
*
* 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'));
}
23 changes: 20 additions & 3 deletions modules/passkey-crypto/src/prfHelpers.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 };
Expand All @@ -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');
}
Expand Down
10 changes: 3 additions & 7 deletions modules/passkey-crypto/src/registerPasskey.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BitGoBase } from '@bitgo/sdk-core';
import { bufferToBase64Url } from './base64url';
import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes';

interface RegisterChallengeResponse {
Expand Down Expand Up @@ -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.
Expand All @@ -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<string, unknown> = {};
Expand Down
46 changes: 46 additions & 0 deletions modules/passkey-crypto/test/unit/base64url.test.ts
Original file line number Diff line number Diff line change
@@ -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]));
});
});
});
10 changes: 7 additions & 3 deletions modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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}$/);
});
});
Loading