Skip to content

Commit 2584a57

Browse files
authored
Merge pull request #20362 from mozilla/FXA-13343.2
fix(passkeys): pass challenge as Buffer to simplewebauthn to fix lookup
2 parents 4ed7c70 + efea187 commit 2584a57

9 files changed

Lines changed: 757 additions & 353 deletions

File tree

libs/accounts/passkey/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ export * from './lib/passkey.config';
2626
export * from './lib/passkey.provider';
2727
export * from './lib/passkey.challenge.manager';
2828
export * from './lib/webauthn-adapter';
29+
export * from './lib/virtual-authenticator';
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
/**
6+
* Minimal virtual WebAuthn authenticator for tests.
7+
*
8+
* Builds cryptographically valid "none"-format attestation and signed
9+
* assertion responses so that tests can exercise the real
10+
* @simplewebauthn/server library without a browser.
11+
*/
12+
13+
import {
14+
createHash,
15+
createSign,
16+
generateKeyPairSync,
17+
randomBytes,
18+
type KeyObject,
19+
} from 'crypto';
20+
import type {
21+
RegistrationResponseJSON,
22+
AuthenticationResponseJSON,
23+
} from '@simplewebauthn/server';
24+
25+
// ---------------------------------------------------------------------------
26+
// Minimal CBOR encoder – just enough for attestationObject + COSE keys
27+
// ---------------------------------------------------------------------------
28+
29+
function cborEncodeLength(majorType: number, length: number): Buffer {
30+
const major = majorType << 5;
31+
if (length < 24) return Buffer.from([major | length]);
32+
if (length < 256) return Buffer.from([major | 24, length]);
33+
if (length < 65536) {
34+
const buf = Buffer.alloc(3);
35+
buf[0] = major | 25;
36+
buf.writeUInt16BE(length, 1);
37+
return buf;
38+
}
39+
throw new Error('CBOR length > 65535 not supported');
40+
}
41+
42+
function cborEncodeValue(value: unknown): Buffer {
43+
if (typeof value === 'number' && Number.isInteger(value)) {
44+
if (value >= 0) return cborEncodeLength(0, value);
45+
return cborEncodeLength(1, -1 - value);
46+
}
47+
if (typeof value === 'string') {
48+
const strBuf = Buffer.from(value, 'utf8');
49+
return Buffer.concat([cborEncodeLength(3, strBuf.length), strBuf]);
50+
}
51+
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
52+
const bytes = Buffer.from(value);
53+
return Buffer.concat([cborEncodeLength(2, bytes.length), bytes]);
54+
}
55+
if (value instanceof Map) {
56+
const header = cborEncodeLength(5, value.size);
57+
const entries: Buffer[] = [header];
58+
for (const [k, v] of value) {
59+
entries.push(cborEncodeValue(k), cborEncodeValue(v));
60+
}
61+
return Buffer.concat(entries);
62+
}
63+
throw new Error(`Unsupported CBOR value: ${typeof value}`);
64+
}
65+
66+
// ---------------------------------------------------------------------------
67+
// Virtual authenticator
68+
// ---------------------------------------------------------------------------
69+
70+
export interface VirtualCredential {
71+
id: Buffer;
72+
privateKey: KeyObject;
73+
publicKey: KeyObject;
74+
signCount: number;
75+
}
76+
77+
/**
78+
* Test-only virtual WebAuthn authenticator.
79+
*
80+
* Generates ES256 key pairs and builds cryptographically valid WebAuthn
81+
* attestation and assertion responses for use in tests.
82+
*/
83+
export class VirtualAuthenticator {
84+
/** Create a fresh ES256 credential with a random 32-byte ID. */
85+
static createCredential(): VirtualCredential {
86+
const { privateKey, publicKey } = generateKeyPairSync('ec', {
87+
namedCurve: 'P-256',
88+
});
89+
return { id: randomBytes(32), privateKey, publicKey, signCount: 0 };
90+
}
91+
92+
/** Build a valid "none"-format attestation response for registration. */
93+
static createAttestationResponse(
94+
cred: VirtualCredential,
95+
input: { challenge: string; origin: string; rpId: string }
96+
): RegistrationResponseJSON {
97+
const jwk = cred.publicKey.export({ format: 'jwk' });
98+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
99+
const x = Buffer.from(jwk.x!, 'base64url');
100+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
101+
const y = Buffer.from(jwk.y!, 'base64url');
102+
103+
const coseKey = new Map<number, unknown>([
104+
[1, 2], // kty: EC2
105+
[3, -7], // alg: ES256
106+
[-1, 1], // crv: P-256
107+
[-2, x],
108+
[-3, y],
109+
]);
110+
111+
const rpIdHash = createHash('sha256').update(input.rpId).digest();
112+
const flags = Buffer.from([0x45]); // UP + UV + AT
113+
const signCountBuf = Buffer.alloc(4);
114+
const credIdLen = Buffer.alloc(2);
115+
credIdLen.writeUInt16BE(cred.id.length, 0);
116+
117+
const authData = Buffer.concat([
118+
rpIdHash,
119+
flags,
120+
signCountBuf,
121+
Buffer.alloc(16), // aaguid (zeros)
122+
credIdLen,
123+
cred.id,
124+
cborEncodeValue(coseKey),
125+
]);
126+
127+
const attestationObject = cborEncodeValue(
128+
new Map<string, unknown>([
129+
['fmt', 'none'],
130+
['attStmt', new Map()],
131+
['authData', authData],
132+
])
133+
);
134+
135+
const clientDataJSON = JSON.stringify({
136+
type: 'webauthn.create',
137+
challenge: input.challenge,
138+
origin: input.origin,
139+
});
140+
141+
return {
142+
id: cred.id.toString('base64url'),
143+
rawId: cred.id.toString('base64url'),
144+
response: {
145+
clientDataJSON: Buffer.from(clientDataJSON).toString('base64url'),
146+
attestationObject: attestationObject.toString('base64url'),
147+
transports: ['internal'],
148+
},
149+
type: 'public-key',
150+
clientExtensionResults: {},
151+
authenticatorAttachment: 'platform',
152+
};
153+
}
154+
155+
/** Build a valid signed assertion response for authentication. */
156+
static createAssertionResponse(
157+
cred: VirtualCredential,
158+
input: { challenge: string; origin: string; rpId: string }
159+
): AuthenticationResponseJSON {
160+
cred.signCount++;
161+
162+
const rpIdHash = createHash('sha256').update(input.rpId).digest();
163+
const flags = Buffer.from([0x05]); // UP + UV
164+
const signCountBuf = Buffer.alloc(4);
165+
signCountBuf.writeUInt32BE(cred.signCount, 0);
166+
167+
const authenticatorData = Buffer.concat([rpIdHash, flags, signCountBuf]);
168+
169+
const clientDataJSON = Buffer.from(
170+
JSON.stringify({
171+
type: 'webauthn.get',
172+
challenge: input.challenge,
173+
origin: input.origin,
174+
})
175+
);
176+
const clientDataHash = createHash('sha256').update(clientDataJSON).digest();
177+
178+
const signature = createSign('SHA256')
179+
.update(Buffer.concat([authenticatorData, clientDataHash]))
180+
.sign(cred.privateKey);
181+
182+
return {
183+
id: cred.id.toString('base64url'),
184+
rawId: cred.id.toString('base64url'),
185+
response: {
186+
clientDataJSON: clientDataJSON.toString('base64url'),
187+
authenticatorData: authenticatorData.toString('base64url'),
188+
signature: signature.toString('base64url'),
189+
},
190+
type: 'public-key',
191+
clientExtensionResults: {},
192+
};
193+
}
194+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
/**
6+
* Integration tests for the webauthn-adapter that exercise the real
7+
* @simplewebauthn/server library (no mocking). A minimal virtual
8+
* authenticator builds valid "none"-format attestation responses so
9+
* we can verify the full challenge roundtrip through generate → verify.
10+
*/
11+
12+
import { randomBytes } from 'crypto';
13+
import {
14+
generateWebauthnRegistrationOptions,
15+
verifyWebauthnRegistrationResponse,
16+
} from './webauthn-adapter';
17+
import { PasskeyConfig } from './passkey.config';
18+
import { VirtualAuthenticator } from './virtual-authenticator';
19+
20+
const TEST_RP_ID = 'accounts.firefox.com';
21+
const TEST_ORIGIN = 'https://accounts.firefox.com';
22+
23+
function testConfig(): PasskeyConfig {
24+
return new PasskeyConfig({
25+
enabled: true,
26+
rpId: TEST_RP_ID,
27+
allowedOrigins: [TEST_ORIGIN],
28+
maxPasskeysPerUser: 10,
29+
challengeTimeout: 30_000,
30+
userVerification: 'required',
31+
residentKey: 'required',
32+
});
33+
}
34+
35+
describe('webauthn-adapter (real @simplewebauthn/server)', () => {
36+
const config = testConfig();
37+
38+
describe('challenge roundtrip', () => {
39+
it('generateWebauthnRegistrationOptions returns the original base64url challenge unchanged', async () => {
40+
const challenge = randomBytes(32).toString('base64url');
41+
42+
const options = await generateWebauthnRegistrationOptions(config, {
43+
uid: Buffer.alloc(16, 0xaa),
44+
45+
challenge,
46+
});
47+
48+
expect(options.challenge).toBe(challenge);
49+
});
50+
51+
it('verifyWebauthnRegistrationResponse succeeds when the challenge matches', async () => {
52+
const cred = VirtualAuthenticator.createCredential();
53+
const challenge = randomBytes(32).toString('base64url');
54+
55+
const options = await generateWebauthnRegistrationOptions(config, {
56+
uid: Buffer.alloc(16, 0xaa),
57+
58+
challenge,
59+
});
60+
61+
const response = VirtualAuthenticator.createAttestationResponse(cred, {
62+
challenge: options.challenge,
63+
origin: TEST_ORIGIN,
64+
rpId: TEST_RP_ID,
65+
});
66+
67+
const result = await verifyWebauthnRegistrationResponse(config, {
68+
response,
69+
challenge,
70+
});
71+
72+
expect(result.verified).toBe(true);
73+
if (!result.verified) throw new Error('narrowing');
74+
expect(result.data.credentialId).toBeInstanceOf(Buffer);
75+
expect(result.data.publicKey).toBeInstanceOf(Buffer);
76+
expect(result.data.signCount).toBe(0);
77+
expect(result.data.aaguid).toBeInstanceOf(Buffer);
78+
expect(result.data.aaguid.length).toBe(16);
79+
});
80+
81+
it('verifyWebauthnRegistrationResponse rejects a mismatched challenge', async () => {
82+
const cred = VirtualAuthenticator.createCredential();
83+
const realChallenge = randomBytes(32).toString('base64url');
84+
const wrongChallenge = randomBytes(32).toString('base64url');
85+
86+
const options = await generateWebauthnRegistrationOptions(config, {
87+
uid: Buffer.alloc(16, 0xaa),
88+
89+
challenge: realChallenge,
90+
});
91+
92+
const response = VirtualAuthenticator.createAttestationResponse(cred, {
93+
challenge: options.challenge,
94+
origin: TEST_ORIGIN,
95+
rpId: TEST_RP_ID,
96+
});
97+
98+
// Verify with a different challenge — should fail
99+
await expect(
100+
verifyWebauthnRegistrationResponse(config, {
101+
response,
102+
challenge: wrongChallenge,
103+
})
104+
).rejects.toThrow();
105+
});
106+
107+
it('verifyWebauthnRegistrationResponse rejects a wrong origin', async () => {
108+
const cred = VirtualAuthenticator.createCredential();
109+
const challenge = randomBytes(32).toString('base64url');
110+
111+
const options = await generateWebauthnRegistrationOptions(config, {
112+
uid: Buffer.alloc(16, 0xaa),
113+
114+
challenge,
115+
});
116+
117+
const response = VirtualAuthenticator.createAttestationResponse(cred, {
118+
challenge: options.challenge,
119+
origin: 'https://evil.example.com',
120+
rpId: TEST_RP_ID,
121+
});
122+
123+
await expect(
124+
verifyWebauthnRegistrationResponse(config, { response, challenge })
125+
).rejects.toThrow();
126+
});
127+
128+
it('verifyWebauthnRegistrationResponse rejects a wrong rpId', async () => {
129+
const cred = VirtualAuthenticator.createCredential();
130+
const challenge = randomBytes(32).toString('base64url');
131+
132+
const options = await generateWebauthnRegistrationOptions(config, {
133+
uid: Buffer.alloc(16, 0xaa),
134+
135+
challenge,
136+
});
137+
138+
const response = VirtualAuthenticator.createAttestationResponse(cred, {
139+
challenge: options.challenge,
140+
origin: TEST_ORIGIN,
141+
rpId: 'evil.example.com',
142+
});
143+
144+
await expect(
145+
verifyWebauthnRegistrationResponse(config, { response, challenge })
146+
).rejects.toThrow();
147+
});
148+
});
149+
150+
describe('credential data extraction', () => {
151+
it('extracts transports and backup flags from a verified response', async () => {
152+
const cred = VirtualAuthenticator.createCredential();
153+
const challenge = randomBytes(32).toString('base64url');
154+
155+
const options = await generateWebauthnRegistrationOptions(config, {
156+
uid: Buffer.alloc(16, 0xaa),
157+
158+
challenge,
159+
});
160+
161+
const response = VirtualAuthenticator.createAttestationResponse(cred, {
162+
challenge: options.challenge,
163+
origin: TEST_ORIGIN,
164+
rpId: TEST_RP_ID,
165+
});
166+
167+
const result = await verifyWebauthnRegistrationResponse(config, {
168+
response,
169+
challenge,
170+
});
171+
172+
expect(result.verified).toBe(true);
173+
if (!result.verified) throw new Error('narrowing');
174+
expect(result.data.credentialId.equals(cred.id)).toBe(true);
175+
expect(result.data.transports).toEqual(['internal']);
176+
expect(typeof result.data.backupEligible).toBe('boolean');
177+
expect(typeof result.data.backupState).toBe('boolean');
178+
});
179+
});
180+
});

0 commit comments

Comments
 (0)