Skip to content

Commit b60d108

Browse files
committed
fix(web-demo): pad base64url before atob in toArrayBuffer
passkey-crypto stores PRF salts and credential IDs as canonical base64url (no padding). atob requires the input length to be a multiple of 4 — without the padding it silently drops the trailing partial group and the PRF extension receives a salt that is missing its last byte. Pad with '=' up to the next multiple of 4 before atob. Also: pin @bitgo/passkey-crypto to ^0.2.0 in web-demo; tighten derivePasskeyPrfKey (dual webauthnDevices / webAuthnDevices, narrow auth JSON, typed allowCredential ids). Rebased onto master: prfHelpers stays on WebauthnDevice + toBase64Url. Refs: WCN-194 TICKET: WCN-194
1 parent ac763a8 commit b60d108

3 files changed

Lines changed: 53 additions & 29 deletions

File tree

modules/passkey-crypto/src/derivePasskeyPrfKey.ts

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
1-
import type { BitGoBase, IWallet } from '@bitgo/sdk-core';
1+
import type { BitGoBase, IWallet, KeychainWebauthnDevice, KeychainWithEncryptedPrv } from '@bitgo/sdk-core';
22
import { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
33
import { derivePassword } from './derivePassword';
44
import type { WebAuthnProvider } from './webAuthnTypes';
55

6-
interface AuthChallengeResponse {
7-
challenge: string;
8-
allowCredentials?: Array<{ id: string; type: string; transports?: string[] }>;
9-
origin?: string;
6+
/** API payloads may use either spelling for the webauthn device list. */
7+
type UserKeychainResponse = KeychainWithEncryptedPrv & {
8+
webAuthnDevices?: KeychainWebauthnDevice[];
9+
};
10+
11+
function webauthnDevicesFromKeychain(keychain: UserKeychainResponse): KeychainWebauthnDevice[] | undefined {
12+
const lower = keychain.webauthnDevices;
13+
if (lower !== undefined && lower.length > 0) {
14+
return lower;
15+
}
16+
const upper = keychain.webAuthnDevices;
17+
if (upper !== undefined && upper.length > 0) {
18+
return upper;
19+
}
20+
return undefined;
21+
}
22+
23+
function challengeFromAuthResponse(body: unknown): string {
24+
if (typeof body !== 'object' || body === null) {
25+
throw new Error('Invalid assertion challenge response');
26+
}
27+
const rec = body as Record<string, unknown>;
28+
if (typeof rec.challenge !== 'string') {
29+
throw new Error('Invalid assertion challenge response');
30+
}
31+
return rec.challenge;
1032
}
1133

1234
/**
@@ -23,45 +45,42 @@ export async function derivePasskeyPrfKey(params: {
2345
}): Promise<string> {
2446
const { bitgo, wallet, provider } = params;
2547

26-
// Fetch the wallet's user keychain to get webauthnDevices
27-
const keychain = await wallet.getEncryptedUserKeychain();
28-
const devices = (keychain as any).webauthnDevices ?? (keychain as any).webAuthnDevices;
48+
const keychain: UserKeychainResponse = await wallet.getEncryptedUserKeychain();
49+
const devices = webauthnDevicesFromKeychain(keychain);
2950

3051
if (!devices || devices.length === 0) {
3152
throw new Error('No passkey devices available');
3253
}
3354

34-
// Build PRF eval map from devices
35-
const { evalByCredential } = buildEvalByCredential(devices as Parameters<typeof buildEvalByCredential>[0]);
55+
const { evalByCredential } = buildEvalByCredential(devices);
3656

3757
if (Object.keys(evalByCredential).length === 0) {
3858
throw new Error('No passkey devices available with a valid PRF salt');
3959
}
4060

41-
// Fetch a server-issued assertion challenge via the auth endpoint
42-
const { challenge } = (await bitgo.get(bitgo.url('/user/otp/webauthn/auth', 2)).result()) as AuthChallengeResponse;
61+
const challenge = challengeFromAuthResponse(await bitgo.get(bitgo.url('/user/otp/webauthn/auth', 2)).result());
62+
63+
const allowCredentials = Object.keys(evalByCredential).map((credId) => {
64+
const nodeBuf = Buffer.from(credId.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
65+
const id = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.byteLength);
66+
return {
67+
type: 'public-key' as const,
68+
id,
69+
};
70+
});
4371

44-
// Build allowCredentials so the browser knows which credentials to use.
45-
// Pass the Buffer (Uint8Array) directly — not .buffer — so the provider
46-
// layer can correctly slice it via ArrayBuffer.isView.
47-
const allowCredentials = Object.keys(evalByCredential).map((credId) => ({
48-
type: 'public-key' as const,
49-
id: Buffer.from(credId.replace(/-/g, '+').replace(/_/g, '/'), 'base64') as unknown as ArrayBuffer,
50-
}));
72+
const publicKey: PublicKeyCredentialRequestOptions = {
73+
challenge: new Uint8Array(Buffer.from(challenge, 'base64')),
74+
allowCredentials,
75+
};
5176

52-
// Trigger WebAuthn assertion with PRF evaluation via the provider (navigator layer)
5377
const result = await provider.get({
54-
publicKey: {
55-
challenge: Buffer.from(challenge, 'base64'),
56-
allowCredentials,
57-
} as PublicKeyCredentialRequestOptions,
78+
publicKey,
5879
evalByCredential,
5980
});
6081

61-
// Verify the credential matches a known device
62-
matchDeviceByCredentialId(devices as Parameters<typeof matchDeviceByCredentialId>[0], result.credentialId);
82+
matchDeviceByCredentialId(devices, result.credentialId);
6383

64-
// Derive and return hex-encoded wallet passphrase
6584
if (!result.prfResult) {
6685
throw new Error('PRF output was not returned by the authenticator');
6786
}

modules/web-demo/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"@bitgo/sdk-coin-xtz": "^2.10.7",
6161
"@bitgo/sdk-coin-zec": "^2.8.7",
6262
"@bitgo/sdk-core": "^36.44.0",
63-
"@bitgo/passkey-crypto": "*",
63+
"@bitgo/passkey-crypto": "^0.2.0",
6464
"@bitgo/sdk-hmac": "^1.9.0",
6565
"@bitgo/sdk-lib-mpc": "^10.12.0",
6666
"@bitgo/sdk-opensslbytes": "^2.1.0",

modules/web-demo/src/components/PasskeyDemo/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,12 @@ function toArrayBuffer(val: any): ArrayBuffer {
166166
) as ArrayBuffer;
167167
}
168168
if (typeof val === 'string') {
169-
const b64 = val.replace(/-/g, '+').replace(/_/g, '/');
169+
// Treat strings as base64url. Pad to a multiple of 4 before atob so
170+
// unpadded base64url (the canonical form used by passkey-crypto) decodes
171+
// correctly — without padding atob silently drops the trailing byte and
172+
// the PRF receives the wrong salt.
173+
let b64 = val.replace(/-/g, '+').replace(/_/g, '/');
174+
while (b64.length % 4 !== 0) b64 += '=';
170175
const binary = atob(b64);
171176
const bytes = new Uint8Array(binary.length);
172177
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);

0 commit comments

Comments
 (0)