Skip to content

Commit 5697169

Browse files
Merge pull request #8715 from BitGo/WCN-410/passkey-crypto-base64url-salt
fix(passkey-crypto): use base64url everywhere for the PRF salt
2 parents 31c2eef + 715c955 commit 5697169

7 files changed

Lines changed: 121 additions & 32 deletions

File tree

modules/passkey-crypto/src/attachPasskeyToWallet.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BitGoBase, Keychain } from '@bitgo/sdk-core';
2+
import { base64UrlToBuffer } from './base64url';
23
import { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
34
import { derivePassword } from './derivePassword';
45
import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes';
@@ -37,47 +38,41 @@ export async function attachPasskeyToWallet(params: {
3738
const keychain = await wallet.getEncryptedUserKeychain();
3839
const keychainId = keychain.id;
3940

40-
// Derive enterprise-scoped salt
41-
const enterpriseSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId);
41+
// Derive enterprise-scoped salt (base64url; same encoding is used as the
42+
// PRF eval input and as the server-stored prfSalt so the bytes fed to the
43+
// authenticator match between attach and derive).
44+
const prfSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId);
4245

4346
// Decrypt private key with existing passphrase
4447
const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv });
4548

4649
// Decode credentialId from base64url to ArrayBuffer for allowCredentials.
4750
// The WebAuthn spec requires allowCredentials to be non-empty when using evalByCredential,
4851
// and each entry must correspond to a key in the evalByCredential map.
49-
const credentialIdBuffer = Buffer.from(device.credentialId.replace(/-/g, '+').replace(/_/g, '/'), 'base64').buffer;
52+
const credentialIdBuffer = base64UrlToBuffer(device.credentialId).buffer;
5053

51-
// PRF assertion — evalByCredential maps this device's credentialId to its enterprise salt
54+
// PRF assertion — evalByCredential maps this device's credentialId to the
55+
// base64url enterprise salt. The provider layer is responsible for decoding
56+
// base64url to raw bytes before handing it to the WebAuthn PRF extension.
5257
const authResult = await provider.get({
5358
publicKey: {
5459
allowCredentials: [{ type: 'public-key', id: credentialIdBuffer }],
5560
} as PublicKeyCredentialRequestOptions,
56-
evalByCredential: { [device.credentialId]: enterpriseSalt },
61+
evalByCredential: { [device.credentialId]: prfSalt },
5762
});
5863

5964
if (!authResult.prfResult) {
6065
throw new Error('PRF assertion did not return a result.');
6166
}
6267

63-
// Derive password from PRF output and re-encrypt
6468
const prfPassword = derivePassword(authResult.prfResult);
6569
const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey });
6670

67-
// Convert enterpriseSalt from hex to base64url (URL-safe, no padding)
68-
// as required by the server's prfSalt validation.
69-
const prfSaltBase64url = Buffer.from(enterpriseSalt, 'hex')
70-
.toString('base64')
71-
.replace(/\+/g, '-')
72-
.replace(/\//g, '_')
73-
.replace(/=+$/, '');
74-
75-
// PUT webauthnInfo to keychain endpoint
7671
const updatedKeychain = await bitgo
7772
.put(bitgo.url(`/${coin}/key/${keychainId}`, 2))
7873
.send({
7974
webauthnInfo: {
80-
prfSalt: prfSaltBase64url,
75+
prfSalt,
8176
otpDeviceId: device.id,
8277
encryptedPrv,
8378
},
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Base64url encoding helpers.
3+
*
4+
* Base64url uses the same alphabet as standard base64 except `+` becomes `-`,
5+
* `/` becomes `_`, and padding (`=`) is stripped. Browser WebAuthn APIs and
6+
* the BitGo server both use base64url for credential IDs and PRF salts, so we
7+
* normalise to it everywhere on the client to avoid mismatches caused by
8+
* mixing encodings.
9+
*/
10+
11+
/** Converts a standard base64 string (or already-base64url string) to base64url. */
12+
export function toBase64Url(s: string): string {
13+
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
14+
}
15+
16+
/** Encodes an ArrayBuffer or Buffer as a base64url string (no padding). */
17+
export function bufferToBase64Url(buffer: ArrayBuffer | Buffer): string {
18+
return toBase64Url(Buffer.from(buffer as ArrayBuffer).toString('base64'));
19+
}
20+
21+
/** Decodes a base64url string into a Buffer. */
22+
export function base64UrlToBuffer(s: string): Buffer {
23+
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
24+
}
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import { createHmac } from 'crypto';
2+
import { base64UrlToBuffer, toBase64Url } from './base64url';
23

34
/**
45
* Derives an enterprise-scoped PRF salt to prevent cross-enterprise key reuse.
56
*
67
* Computes HMAC-SHA256(key=prfSalt_base64url_decoded, data=enterpriseId_utf8).
78
* The baseSalt must always come from the server — never generate it client-side.
89
*
10+
* Returns base64url so the same encoding is used everywhere the salt is handled
11+
* (server storage, PRF eval input, prfHelpers lookup). Mixing encodings
12+
* (e.g. hex on the client, base64url on the server) caused the PRF to receive
13+
* different bytes during attach vs derive in browser environments where
14+
* `Buffer.toString('hex')` is unreliable.
15+
*
916
* @param baseSalt - Server-provided base64url-encoded PRF salt
1017
* @param enterpriseId - Enterprise identifier
11-
* @returns Hex-encoded HMAC-SHA256 digest
18+
* @returns Base64url-encoded HMAC-SHA256 digest (no padding)
1219
*/
1320
export function deriveEnterpriseSalt(baseSalt: string, enterpriseId: string): string {
14-
const keyBytes = Buffer.from(baseSalt.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
15-
return createHmac('sha256', keyBytes).update(enterpriseId).digest('hex');
21+
const keyBytes = base64UrlToBuffer(baseSalt);
22+
return toBase64Url(createHmac('sha256', keyBytes).update(enterpriseId).digest('base64'));
1623
}

modules/passkey-crypto/src/prfHelpers.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { WebauthnDevice } from '@bitgo/public-types';
2+
import { toBase64Url } from './base64url';
23

34
/**
45
* Builds the PRF eval map and credential-to-device lookup from a wallet
@@ -14,8 +15,22 @@ export function buildEvalByCredential(devices: WebauthnDevice[]): {
1415
for (const device of devices) {
1516
if (!device.prfSalt) continue;
1617
const { credID } = device.authenticatorInfo;
17-
evalByCredential[credID] = device.prfSalt;
18-
credIdToDevice.set(credID, device);
18+
19+
// Normalise credID to base64url (no padding, URL-safe chars) so it matches
20+
// the key format used by attachPasskeyToWallet (device.credentialId from the
21+
// browser, which is already base64url). The WebAuthn PRF extension looks up
22+
// the selected credential's ID against evalByCredential keys — if the encoding
23+
// differs (e.g. standard base64 with padding/+/), the lookup silently fails and
24+
// PRF evaluates with no salt, producing a different output.
25+
const credIdBase64url = toBase64Url(credID);
26+
27+
// Pass prfSalt through as-is (base64url). attachPasskeyToWallet writes the
28+
// server-stored salt in the same encoding and feeds the same string to
29+
// the PRF extension at attach time, so both paths produce the same salt
30+
// bytes — provided the WebAuthn provider layer decodes base64url before
31+
// handing the bytes to navigator.credentials.get.
32+
evalByCredential[credIdBase64url] = device.prfSalt;
33+
credIdToDevice.set(credIdBase64url, device);
1934
}
2035

2136
return { evalByCredential, credIdToDevice };
@@ -26,7 +41,9 @@ export function buildEvalByCredential(devices: WebauthnDevice[]): {
2641
* @throws if no matching device is found
2742
*/
2843
export function matchDeviceByCredentialId(devices: WebauthnDevice[], credentialId: string): WebauthnDevice {
29-
const device = devices.find((d) => d.authenticatorInfo.credID === credentialId);
44+
// Normalise both sides to base64url so padding/char differences don't break matching.
45+
const needle = toBase64Url(credentialId);
46+
const device = devices.find((d) => toBase64Url(d.authenticatorInfo.credID) === needle);
3047
if (!device) {
3148
throw new Error('Could not identify which passkey device was used');
3249
}

modules/passkey-crypto/src/registerPasskey.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BitGoBase } from '@bitgo/sdk-core';
2+
import { bufferToBase64Url } from './base64url';
23
import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes';
34

45
interface RegisterChallengeResponse {
@@ -28,11 +29,6 @@ interface RegisterOtpResponse {
2829
};
2930
}
3031

31-
/** Encodes an ArrayBuffer as a base64url string (no padding). */
32-
function encodeBase64Url(buffer: ArrayBuffer): string {
33-
return Buffer.from(buffer).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
34-
}
35-
3632
/**
3733
* Recursively converts a PublicKeyCredential (or any value it contains) to a
3834
* JSON-serialisable representation, encoding ArrayBuffers as base64url strings.
@@ -42,10 +38,10 @@ function publicKeyCredentialToJSON(value: unknown): unknown {
4238
return value.map(publicKeyCredentialToJSON);
4339
}
4440
if (value instanceof ArrayBuffer) {
45-
return encodeBase64Url(value);
41+
return bufferToBase64Url(value);
4642
}
4743
if (ArrayBuffer.isView(value)) {
48-
return encodeBase64Url(value.buffer as ArrayBuffer);
44+
return bufferToBase64Url(value.buffer as ArrayBuffer);
4945
}
5046
if (value instanceof Object) {
5147
const result: Record<string, unknown> = {};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as assert from 'assert';
2+
import { base64UrlToBuffer, bufferToBase64Url, toBase64Url } from '../../src/base64url';
3+
4+
describe('base64url helpers', function () {
5+
describe('toBase64Url', function () {
6+
it('replaces +, / and strips padding', function () {
7+
assert.strictEqual(toBase64Url('a+b/c=='), 'a-b_c');
8+
});
9+
10+
it('is a no-op on already-base64url input', function () {
11+
assert.strictEqual(toBase64Url('a-b_c'), 'a-b_c');
12+
});
13+
14+
it('handles empty string', function () {
15+
assert.strictEqual(toBase64Url(''), '');
16+
});
17+
});
18+
19+
describe('bufferToBase64Url', function () {
20+
it('encodes a Buffer to unpadded base64url', function () {
21+
// bytes that produce + and / in standard base64
22+
const buf = Buffer.from([0xfb, 0xff, 0xbf]);
23+
assert.strictEqual(buf.toString('base64'), '+/+/');
24+
assert.strictEqual(bufferToBase64Url(buf), '-_-_');
25+
});
26+
27+
it('encodes an ArrayBuffer to unpadded base64url', function () {
28+
const ab = new Uint8Array([0xff, 0xfe, 0xfd]).buffer;
29+
assert.strictEqual(bufferToBase64Url(ab), '__79');
30+
});
31+
});
32+
33+
describe('base64UrlToBuffer', function () {
34+
it('round-trips through bufferToBase64Url', function () {
35+
const original = Buffer.from([0x00, 0xff, 0x10, 0x20, 0xab, 0xcd]);
36+
const encoded = bufferToBase64Url(original);
37+
const decoded = base64UrlToBuffer(encoded);
38+
assert.deepStrictEqual(decoded, original);
39+
});
40+
41+
it('decodes base64url with - and _ chars', function () {
42+
const decoded = base64UrlToBuffer('-_-_');
43+
assert.deepStrictEqual(decoded, Buffer.from([0xfb, 0xff, 0xbf]));
44+
});
45+
});
46+
});

modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { deriveEnterpriseSalt } from '../../src';
55
const REAL_FIXTURE = {
66
basePrfSalt: 'ZqJ64M2dL65zn2-Jxd58SMN2ILc9QjbCFxUTGHd_LC8',
77
enterpriseId: '69c2aea1a3d7bc07f7f775c0ca86b0ec',
8-
expectedDerivedSalt: 'a226ac3aace4bb2b84cfff34e37fb7217620852bb72d5e0dfdad4c2c8473994f',
8+
// base64url encoding of the HMAC-SHA256(baseSalt_decoded, enterpriseId) digest.
9+
// Same encoding the server stores and the WebAuthn PRF extension consumes — keeping
10+
// one encoding everywhere avoids the hex/base64url mismatch that broke browser PRF.
11+
expectedDerivedSalt: 'oiasOqzkuyuEz_8043-3IXYghSu3LV4N_a1MLIRzmU8',
912
};
1013

1114
describe('deriveEnterpriseSalt', function () {
@@ -37,10 +40,11 @@ describe('deriveEnterpriseSalt', function () {
3740
assert.notStrictEqual(saltA, saltB);
3841
});
3942

40-
it('returns a non-empty hex string', function () {
43+
it('returns a non-empty unpadded base64url string', function () {
4144
const result = deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, REAL_FIXTURE.enterpriseId);
4245
assert.strictEqual(typeof result, 'string');
4346
assert.ok(result.length > 0);
44-
assert.match(result, /^[0-9a-f]{64}$/);
47+
// Base64url alphabet, no padding. SHA-256 = 32 bytes → 43 base64url chars.
48+
assert.match(result, /^[A-Za-z0-9_-]{43}$/);
4549
});
4650
});

0 commit comments

Comments
 (0)