Skip to content

Commit 295ba0b

Browse files
authored
Merge pull request #20753 from mozilla/FXA-13978-request-prf-extension
fix(passkey): record prfEnabled by requesting PRF at registration
2 parents 0e8bb4d + 96aafeb commit 295ba0b

4 files changed

Lines changed: 111 additions & 26 deletions

File tree

libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
verifyWebauthnRegistrationResponse,
99
generateWebauthnAuthenticationOptions,
1010
verifyWebauthnAuthenticationResponse,
11+
extractPrfEnabled,
1112
} from './webauthn-adapter';
1213
import { PasskeyConfig } from './passkey.config';
1314
import { VirtualAuthenticator } from './virtual-authenticator';
@@ -159,6 +160,50 @@ describe('generateWebauthnRegistrationOptions', () => {
159160
{ id: credId, type: 'public-key' },
160161
]);
161162
});
163+
164+
it('requests the PRF extension so authenticators report PRF support', async () => {
165+
const options = await generateWebauthnRegistrationOptions(testConfig(), {
166+
uid: Buffer.alloc(16, 0xbb).toString('hex'),
167+
email: 'bob@example.com',
168+
challenge: randomBytes(32).toString('base64url'),
169+
});
170+
171+
// credProps is added by SimpleWebAuthn itself; we only own the PRF probe.
172+
expect(options.extensions).toEqual(expect.objectContaining({ prf: {} }));
173+
});
174+
});
175+
176+
describe('extractPrfEnabled', () => {
177+
it.each([
178+
{
179+
label: 'prf.enabled is true',
180+
input: { prf: { enabled: true } },
181+
expected: true,
182+
},
183+
{
184+
label: 'prf.enabled is false',
185+
input: { prf: { enabled: false } },
186+
expected: false,
187+
},
188+
{
189+
label: 'prf is present without enabled',
190+
input: { prf: {} },
191+
expected: false,
192+
},
193+
{
194+
label: 'prf.enabled is a non-boolean truthy value',
195+
input: { prf: { enabled: 'false' } },
196+
expected: false,
197+
},
198+
{ label: 'prf is absent', input: {}, expected: false },
199+
{
200+
label: 'client extension results are undefined',
201+
input: undefined,
202+
expected: false,
203+
},
204+
])('returns $expected when $label', ({ input, expected }) => {
205+
expect(extractPrfEnabled(input)).toBe(expected);
206+
});
162207
});
163208

164209
describe('verifyWebauthnRegistrationResponse', () => {
@@ -186,18 +231,48 @@ describe('verifyWebauthnRegistrationResponse', () => {
186231
});
187232

188233
expect(result.verified).toBe(true);
189-
if (!result.verified) throw new Error('narrowing');
190234

191-
expect(typeof result.data.credentialId).toBe('string');
192-
expect(result.data.credentialId).toBe(cred.id.toString('base64url'));
193-
expect(result.data.publicKey).toBeInstanceOf(Buffer);
194-
expect(result.data.signCount).toBe(0);
195-
expect(result.data.transports).toEqual(['internal']);
196-
expect(typeof result.data.aaguid).toBe('string');
197-
expect(result.data.aaguid).toBe('00000000-0000-0000-0000-000000000000');
198-
expect(result.data.backupEligible).toBe(false);
199-
expect(result.data.backupState).toBe(false);
200-
expect(result.data.prfEnabled).toBe(false);
235+
expect(typeof result.data?.credentialId).toBe('string');
236+
expect(result.data?.credentialId).toBe(cred.id.toString('base64url'));
237+
expect(result.data?.publicKey).toBeInstanceOf(Buffer);
238+
expect(result.data?.signCount).toBe(0);
239+
expect(result.data?.transports).toEqual(['internal']);
240+
expect(typeof result.data?.aaguid).toBe('string');
241+
expect(result.data?.aaguid).toBe('00000000-0000-0000-0000-000000000000');
242+
expect(result.data?.backupEligible).toBe(false);
243+
expect(result.data?.backupState).toBe(false);
244+
// No prf in clientExtensionResults → not PRF-enabled.
245+
expect(result.data?.prfEnabled).toBe(false);
246+
});
247+
248+
it('records prfEnabled when the browser reports PRF support', async () => {
249+
const cred = VirtualAuthenticator.createCredential();
250+
const challenge = randomBytes(32).toString('base64url');
251+
252+
const options = await generateWebauthnRegistrationOptions(config, {
253+
uid: Buffer.alloc(16, 0xaa).toString('hex'),
254+
email: 'test@example.com',
255+
challenge,
256+
});
257+
258+
const attestation = VirtualAuthenticator.createAttestationResponse(cred, {
259+
challenge: options.challenge,
260+
origin: TEST_ORIGIN,
261+
rpId: TEST_RP_ID,
262+
});
263+
// prf.enabled is a WebAuthn Level 3 client-extension output the browser
264+
// sets; the virtual authenticator leaves clientExtensionResults empty, so
265+
// attach it here. (Field absent from SimpleWebAuthn's output type.)
266+
(attestation.clientExtensionResults as { prf?: { enabled: boolean } }).prf =
267+
{ enabled: true };
268+
269+
const result = await verifyWebauthnRegistrationResponse(config, {
270+
response: attestation,
271+
challenge,
272+
});
273+
274+
expect(result.verified).toBe(true);
275+
expect(result.data?.prfEnabled).toBe(true);
201276
});
202277

203278
it('rejects a mismatched challenge', async () => {
@@ -380,9 +455,8 @@ describe('verifyWebauthnAuthenticationResponse', () => {
380455
});
381456

382457
expect(result.verified).toBe(true);
383-
if (!result.verified) throw new Error('narrowing');
384-
expect(result.data.newSignCount).toBe(1);
385-
expect(result.data.backupState).toBe(false);
458+
expect(result.data?.newSignCount).toBe(1);
459+
expect(result.data?.backupState).toBe(false);
386460
});
387461

388462
it('tracks incrementing sign counts across assertions', async () => {

libs/accounts/passkey/src/lib/webauthn-adapter.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
PublicKeyCredentialCreationOptionsJSON,
1717
PublicKeyCredentialRequestOptionsJSON,
1818
AuthenticatorTransportFuture,
19+
AuthenticationExtensionsClientInputs,
1920
} from '@simplewebauthn/server';
2021

2122
import { uuidTransformer } from '@fxa/shared/db/mysql/core';
@@ -79,6 +80,9 @@ export async function generateWebauthnRegistrationOptions(
7980
authenticatorAttachment: config.authenticatorAttachment,
8081
},
8182
excludeCredentials: input.excludeCredentials,
83+
// Probe PRF capability only (no eval) for passkey Phase 1.
84+
// Cast: SimpleWebAuthn's bundled type predates PRF.
85+
extensions: { prf: {} } as AuthenticationExtensionsClientInputs,
8286
});
8387
}
8488

@@ -107,11 +111,12 @@ export type RegistrationVerificationResult =
107111
| { verified: true; data: VerifiedRegistrationData };
108112

109113
/**
110-
* Extract the PRF `enabled` flag from authenticator extension results.
114+
* Extract the PRF `enabled` flag from the browser's client extension results.
111115
*/
112-
function extractPrfEnabled(extensions: unknown): boolean {
113-
return Boolean(
114-
(extensions as { prf?: { enabled?: unknown } } | undefined)?.prf?.enabled
116+
export function extractPrfEnabled(extensions: unknown): boolean {
117+
return (
118+
(extensions as { prf?: { enabled?: unknown } } | undefined)?.prf
119+
?.enabled === true
115120
);
116121
}
117122

@@ -142,13 +147,8 @@ export async function verifyWebauthnRegistrationResponse(
142147
return { verified: false };
143148
}
144149

145-
const {
146-
credential,
147-
aaguid,
148-
credentialDeviceType,
149-
credentialBackedUp,
150-
authenticatorExtensionResults,
151-
} = registrationInfo;
150+
const { credential, aaguid, credentialDeviceType, credentialBackedUp } =
151+
registrationInfo;
152152

153153
return {
154154
verified: true,
@@ -160,7 +160,7 @@ export async function verifyWebauthnRegistrationResponse(
160160
aaguid,
161161
backupEligible: credentialDeviceType === 'multiDevice',
162162
backupState: credentialBackedUp,
163-
prfEnabled: extractPrfEnabled(authenticatorExtensionResults),
163+
prfEnabled: extractPrfEnabled(input.response.clientExtensionResults),
164164
},
165165
};
166166
}

packages/fxa-auth-server/lib/routes/passkeys.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,16 @@ export const passkeyRoutes = (
749749
credProps: isA.boolean().optional(),
750750
hmacCreateSecret: isA.boolean().optional(),
751751
minPinLength: isA.boolean().optional(),
752+
prf: isA
753+
.object({
754+
eval: isA
755+
.object({
756+
first: isA.string().required(),
757+
second: isA.string().optional(),
758+
})
759+
.optional(),
760+
})
761+
.optional(),
752762
})
753763
.optional(),
754764
}),

packages/fxa-auth-server/test/remote/passkeys.in.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ describe('#integration - remote passkey registration', () => {
192192
);
193193
expect(options.challenge).toBeDefined();
194194
expect(options.rp).toBeDefined();
195+
expect(options.extensions).toEqual(expect.objectContaining({ prf: {} }));
195196

196197
// Step 2: simulate authenticator
197198
const cred = VirtualAuthenticator.createCredential();

0 commit comments

Comments
 (0)