Skip to content

Commit 8b9e2f5

Browse files
committed
fix(passkey-crypto): change deriveEnterpriseSalt output encoding from base64 to base64url
Retail's hmac.ts now returns base64url-encoded PRF salt. The SDK must match this encoding so HMAC values are compatible and passkey authentication does not silently fail. WCN-410 TICKET: WCN-410
1 parent 8f1038a commit 8b9e2f5

4 files changed

Lines changed: 15 additions & 20 deletions

File tree

modules/passkey-crypto/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
},
3636
"dependencies": {
3737
"@bitgo/public-types": "6.1.0",
38-
"@bitgo/sdk-core": "^36.44.0"
38+
"@bitgo/sdk-core": "^36.44.0",
39+
"@bitgo/sjcl": "^1.1.0"
3940
},
4041
"devDependencies": {
4142
"@types/node": "^18.0.0"

modules/passkey-crypto/src/attachPasskeyToWallet.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ 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 (already base64url-encoded)
41+
const prfSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId);
4242

4343
// Decrypt private key with existing passphrase
4444
const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv });
@@ -53,7 +53,7 @@ export async function attachPasskeyToWallet(params: {
5353
publicKey: {
5454
allowCredentials: [{ type: 'public-key', id: credentialIdBuffer }],
5555
} as PublicKeyCredentialRequestOptions,
56-
evalByCredential: { [device.credentialId]: enterpriseSalt },
56+
evalByCredential: { [device.credentialId]: prfSalt },
5757
});
5858

5959
if (!authResult.prfResult) {
@@ -64,20 +64,12 @@ export async function attachPasskeyToWallet(params: {
6464
const prfPassword = derivePassword(authResult.prfResult);
6565
const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey });
6666

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-
7567
// PUT webauthnInfo to keychain endpoint
7668
const updatedKeychain = await bitgo
7769
.put(bitgo.url(`/${coin}/key/${keychainId}`, 2))
7870
.send({
7971
webauthnInfo: {
80-
prfSalt: prfSaltBase64url,
72+
prfSalt,
8173
otpDeviceId: device.id,
8274
encryptedPrv,
8375
},
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createHmac } from 'crypto';
1+
import * as sjcl from '@bitgo/sjcl';
22

33
/**
44
* Derives an enterprise-scoped PRF salt to prevent cross-enterprise key reuse.
@@ -8,9 +8,11 @@ import { createHmac } from 'crypto';
88
*
99
* @param baseSalt - Server-provided base64url-encoded PRF salt
1010
* @param enterpriseId - Enterprise identifier
11-
* @returns Hex-encoded HMAC-SHA256 digest
11+
* @returns Base64url-encoded HMAC-SHA256 digest
1212
*/
1313
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');
14+
const keyBits = sjcl.codec.base64url.toBits(baseSalt);
15+
const dataBits = sjcl.codec.utf8String.toBits(enterpriseId);
16+
const resultBits = new sjcl.misc.hmac(keyBits, sjcl.hash.sha256).mac(dataBits);
17+
return sjcl.codec.base64url.fromBits(resultBits);
1618
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { deriveEnterpriseSalt } from '../../src';
55
const REAL_FIXTURE = {
66
basePrfSalt: 'ZqJ64M2dL65zn2-Jxd58SMN2ILc9QjbCFxUTGHd_LC8',
77
enterpriseId: '69c2aea1a3d7bc07f7f775c0ca86b0ec',
8-
expectedDerivedSalt: 'a226ac3aace4bb2b84cfff34e37fb7217620852bb72d5e0dfdad4c2c8473994f',
8+
expectedDerivedSalt: 'oiasOqzkuyuEz_8043-3IXYghSu3LV4N_a1MLIRzmU8',
99
};
1010

1111
describe('deriveEnterpriseSalt', function () {
@@ -37,10 +37,10 @@ describe('deriveEnterpriseSalt', function () {
3737
assert.notStrictEqual(saltA, saltB);
3838
});
3939

40-
it('returns a non-empty hex string', function () {
40+
it('returns a non-empty base64url string', function () {
4141
const result = deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, REAL_FIXTURE.enterpriseId);
4242
assert.strictEqual(typeof result, 'string');
4343
assert.ok(result.length > 0);
44-
assert.match(result, /^[0-9a-f]{64}$/);
44+
assert.match(result, /^[A-Za-z0-9_-]+$/);
4545
});
4646
});

0 commit comments

Comments
 (0)