Skip to content

Commit d263f37

Browse files
committed
fix(passkey-crypto): use base64url everywhere for the PRF salt
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
1 parent 9370461 commit d263f37

4 files changed

Lines changed: 49 additions & 23 deletions

File tree

modules/passkey-crypto/src/attachPasskeyToWallet.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ export async function attachPasskeyToWallet(params: {
3737
const keychain = await wallet.getEncryptedUserKeychain();
3838
const keychainId = keychain.id;
3939

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

4345
// Decrypt private key with existing passphrase
4446
const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv });
@@ -48,36 +50,28 @@ export async function attachPasskeyToWallet(params: {
4850
// and each entry must correspond to a key in the evalByCredential map.
4951
const credentialIdBuffer = Buffer.from(device.credentialId.replace(/-/g, '+').replace(/_/g, '/'), 'base64').buffer;
5052

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

5963
if (!authResult.prfResult) {
6064
throw new Error('PRF assertion did not return a result.');
6165
}
6266

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

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
7670
const updatedKeychain = await bitgo
7771
.put(bitgo.url(`/${coin}/key/${keychainId}`, 2))
7872
.send({
7973
webauthnInfo: {
80-
prfSalt: prfSaltBase64url,
74+
prfSalt,
8175
otpDeviceId: device.id,
8276
encryptedPrv,
8377
},

modules/passkey-crypto/src/deriveEnterpriseSalt.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,22 @@ import { createHmac } from 'crypto';
66
* Computes HMAC-SHA256(key=prfSalt_base64url_decoded, data=enterpriseId_utf8).
77
* The baseSalt must always come from the server — never generate it client-side.
88
*
9+
* Returns base64url so the same encoding is used everywhere the salt is handled
10+
* (server storage, PRF eval input, prfHelpers lookup). Mixing encodings
11+
* (e.g. hex on the client, base64url on the server) caused the PRF to receive
12+
* different bytes during attach vs derive in browser environments where
13+
* `Buffer.toString('hex')` is unreliable.
14+
*
915
* @param baseSalt - Server-provided base64url-encoded PRF salt
1016
* @param enterpriseId - Enterprise identifier
11-
* @returns Hex-encoded HMAC-SHA256 digest
17+
* @returns Base64url-encoded HMAC-SHA256 digest (no padding)
1218
*/
1319
export function deriveEnterpriseSalt(baseSalt: string, enterpriseId: string): string {
1420
const keyBytes = Buffer.from(baseSalt.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
15-
return createHmac('sha256', keyBytes).update(enterpriseId).digest('hex');
21+
return createHmac('sha256', keyBytes)
22+
.update(enterpriseId)
23+
.digest('base64')
24+
.replace(/\+/g, '-')
25+
.replace(/\//g, '_')
26+
.replace(/=+$/, '');
1627
}

modules/passkey-crypto/src/prfHelpers.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,22 @@ export function buildEvalByCredential(devices: WebauthnDevice[]): {
1414
for (const device of devices) {
1515
if (!device.prfSalt) continue;
1616
const { credID } = device.authenticatorInfo;
17-
evalByCredential[credID] = device.prfSalt;
18-
credIdToDevice.set(credID, device);
17+
18+
// Normalise credID to base64url (no padding, URL-safe chars) so it matches
19+
// the key format used by attachPasskeyToWallet (device.credentialId from the
20+
// browser, which is already base64url). The WebAuthn PRF extension looks up
21+
// the selected credential's ID against evalByCredential keys — if the encoding
22+
// differs (e.g. standard base64 with padding/+/), the lookup silently fails and
23+
// PRF evaluates with no salt, producing a different output.
24+
const credIdBase64url = credID.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
25+
26+
// Pass prfSalt through as-is (base64url). attachPasskeyToWallet writes the
27+
// server-stored salt in the same encoding and feeds the same string to
28+
// the PRF extension at attach time, so both paths produce the same salt
29+
// bytes — provided the WebAuthn provider layer decodes base64url before
30+
// handing the bytes to navigator.credentials.get.
31+
evalByCredential[credIdBase64url] = device.prfSalt;
32+
credIdToDevice.set(credIdBase64url, device);
1933
}
2034

2135
return { evalByCredential, credIdToDevice };
@@ -26,7 +40,10 @@ export function buildEvalByCredential(devices: WebauthnDevice[]): {
2640
* @throws if no matching device is found
2741
*/
2842
export function matchDeviceByCredentialId(devices: WebauthnDevice[], credentialId: string): WebauthnDevice {
29-
const device = devices.find((d) => d.authenticatorInfo.credID === credentialId);
43+
// Normalise both sides to base64url so padding/char differences don't break matching.
44+
const normalise = (s: string) => s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
45+
const needle = normalise(credentialId);
46+
const device = devices.find((d) => normalise(d.authenticatorInfo.credID) === needle);
3047
if (!device) {
3148
throw new Error('Could not identify which passkey device was used');
3249
}

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)