Skip to content

Commit a780ef3

Browse files
authored
Merge pull request #20228 from mozilla/passkey-security-tests
feat(passkey): add WebAuthn security integration test suite
2 parents 2709d31 + 6b0e2f6 commit a780ef3

1 file changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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+
* Security-focused integration tests for the passkey library.
7+
*
8+
* Standards references:
9+
* - WebAuthn Level 3: https://www.w3.org/TR/webauthn-3/
10+
* - NIST SP 800-63B: https://pages.nist.gov/800-63-3/sp800-63b.html
11+
*/
12+
13+
import { faker } from '@faker-js/faker';
14+
import {
15+
AccountDatabase,
16+
AccountDbProvider,
17+
PasskeyFactory,
18+
testAccountDatabaseSetup,
19+
} from '@fxa/shared/db/mysql/account';
20+
import { AccountManager } from '@fxa/shared/account/account';
21+
import { LOGGER_PROVIDER } from '@fxa/shared/log';
22+
import { StatsDService } from '@fxa/shared/metrics/statsd';
23+
import { Test } from '@nestjs/testing';
24+
import Redis from 'ioredis';
25+
import { PasskeyManager } from './passkey.manager';
26+
import { PasskeyChallengeManager } from './passkey.challenge.manager';
27+
import { PasskeyConfig } from './passkey.config';
28+
import { AppError } from '@fxa/accounts/errors';
29+
import { PASSKEY_CHALLENGE_REDIS } from './passkey.provider';
30+
import { findPasskeyByCredentialId, insertPasskey } from './passkey.repository';
31+
32+
const mockLogger = {
33+
log: jest.fn(),
34+
warn: jest.fn(),
35+
error: jest.fn(),
36+
debug: jest.fn(),
37+
};
38+
39+
let db: AccountDatabase;
40+
let manager: PasskeyManager;
41+
let accountManager: AccountManager;
42+
43+
let redis: Redis.Redis;
44+
let challengeManager: PasskeyChallengeManager;
45+
46+
describe('Passkey Security Tests', () => {
47+
describe('Credential and Data Security', () => {
48+
beforeAll(async () => {
49+
try {
50+
db = await testAccountDatabaseSetup(['accounts', 'emails', 'passkeys']);
51+
accountManager = new AccountManager(db);
52+
53+
const moduleRef = await Test.createTestingModule({
54+
providers: [
55+
PasskeyManager,
56+
{ provide: AccountDbProvider, useValue: db },
57+
{
58+
provide: PasskeyConfig,
59+
useValue: Object.assign(new PasskeyConfig(), {
60+
rpId: 'accounts.example.com',
61+
allowedOrigins: ['https://accounts.example.com'],
62+
maxPasskeysPerUser: 10,
63+
}),
64+
},
65+
{ provide: LOGGER_PROVIDER, useValue: mockLogger },
66+
{ provide: StatsDService, useValue: { increment: jest.fn() } },
67+
],
68+
}).compile();
69+
70+
manager = moduleRef.get(PasskeyManager);
71+
} catch (error) {
72+
console.warn('⚠️ Integration tests require database infrastructure.');
73+
console.warn(
74+
'⚠️ Run "yarn start infrastructure" to enable these tests.'
75+
);
76+
throw error;
77+
}
78+
});
79+
80+
afterAll(async () => {
81+
if (db) {
82+
await db.destroy();
83+
}
84+
});
85+
86+
async function createTestAccount(): Promise<Buffer> {
87+
const email = faker.internet.email();
88+
const uidHex = await accountManager.createAccountStub(email, 1, 'en-US');
89+
return Buffer.from(uidHex, 'hex');
90+
}
91+
92+
// WebAuthn §4: https://www.w3.org/TR/webauthn-3/#credential-id
93+
it('credentialId uniqueness is globally scoped across users', async () => {
94+
const uid1 = await createTestAccount();
95+
const uid2 = await createTestAccount();
96+
const passkey = PasskeyFactory({ uid: uid1 });
97+
98+
await manager.registerPasskey(passkey);
99+
100+
// Same user, same credentialId — also rejected
101+
await expect(manager.registerPasskey(passkey)).rejects.toMatchObject(
102+
AppError.passkeyAlreadyRegistered()
103+
);
104+
// Different user, same credentialId — UNIQUE INDEX is global, not per-user
105+
await expect(
106+
manager.registerPasskey({ ...passkey, uid: uid2 })
107+
).rejects.toMatchObject(AppError.passkeyAlreadyRegistered());
108+
});
109+
110+
// WebAuthn §6.4.1: public keys are binary CBOR — base64 coercion would corrupt key material
111+
// https://www.w3.org/TR/webauthn-3/#sctn-attestation
112+
it('publicKey survives DB round-trip as binary Buffer, not a base64 string', async () => {
113+
const uid = await createTestAccount();
114+
const knownKey = Buffer.from('deadbeefcafebabe'.repeat(8), 'hex');
115+
const passkey = PasskeyFactory({ uid, publicKey: knownKey });
116+
117+
await insertPasskey(db, passkey);
118+
119+
const found = await findPasskeyByCredentialId(db, passkey.credentialId);
120+
expect(found).toBeDefined();
121+
expect(Buffer.isBuffer(found?.publicKey)).toBe(true);
122+
expect(found?.publicKey).toEqual(knownKey);
123+
expect(typeof found?.publicKey).not.toBe('string');
124+
});
125+
126+
// Best practice: credentialIds are arbitrary byte arrays — VARBINARY columns
127+
// can be silently coerced to strings by some drivers/ORMs
128+
// WebAuthn §4.1.1: https://www.w3.org/TR/webauthn-3/#credential-id
129+
it('credentialId survives DB round-trip with non-UTF8 bytes', async () => {
130+
const uid = await createTestAccount();
131+
// 32 bytes in the 0x80–0x9F range — not valid UTF-8
132+
const binaryCredentialId = Buffer.from(
133+
Array.from({ length: 32 }, (_, i) => 0x80 + i)
134+
);
135+
const passkey = PasskeyFactory({ uid, credentialId: binaryCredentialId });
136+
137+
await insertPasskey(db, passkey);
138+
139+
const found = await findPasskeyByCredentialId(db, binaryCredentialId);
140+
expect(found).toBeDefined();
141+
expect(found?.credentialId).toEqual(binaryCredentialId);
142+
});
143+
});
144+
145+
describe('Challenge Security', () => {
146+
const mockStatsd = { increment: jest.fn() };
147+
const challengeLogger = {
148+
log: jest.fn(),
149+
warn: jest.fn(),
150+
error: jest.fn(),
151+
debug: jest.fn(),
152+
};
153+
154+
const fakeUid = () =>
155+
faker.string.hexadecimal({ length: 32, prefix: '', casing: 'lower' });
156+
157+
async function clearChallengeKeys() {
158+
const keys = await redis.keys('passkey:challenge:*');
159+
if (keys.length > 0) {
160+
await redis.del(keys);
161+
}
162+
}
163+
164+
beforeAll(async () => {
165+
redis = new Redis({ host: 'localhost' });
166+
167+
const config = Object.assign(new PasskeyConfig(), {
168+
rpId: 'localhost',
169+
allowedOrigins: ['http://localhost'],
170+
challengeTimeout: 1000 * 60 * 5, // 5 minutes
171+
});
172+
173+
const moduleRef = await Test.createTestingModule({
174+
providers: [
175+
PasskeyChallengeManager,
176+
{ provide: PASSKEY_CHALLENGE_REDIS, useValue: redis },
177+
{ provide: PasskeyConfig, useValue: config },
178+
{ provide: LOGGER_PROVIDER, useValue: challengeLogger },
179+
{ provide: StatsDService, useValue: mockStatsd },
180+
],
181+
}).compile();
182+
183+
challengeManager = moduleRef.get(PasskeyChallengeManager);
184+
});
185+
186+
beforeEach(async () => {
187+
await clearChallengeKeys();
188+
});
189+
190+
afterAll(async () => {
191+
await clearChallengeKeys();
192+
await redis.quit();
193+
});
194+
195+
// WebAuthn §13.4.3 + NIST SP 800-63B §5.1.9.1: challenges must be >= 16 bytes (we require >= 32)
196+
// https://www.w3.org/TR/webauthn-3/#sctn-cryptographic-challenges
197+
// Verifies output is the right size and not constant or cached.
198+
it('generates 100 unique challenges each with >= 32 bytes of entropy', async () => {
199+
const challenges: string[] = [];
200+
201+
for (let i = 0; i < 100; i++) {
202+
const challenge =
203+
await challengeManager.generateRegistrationChallenge(fakeUid());
204+
challenges.push(challenge);
205+
}
206+
207+
expect(new Set(challenges).size).toBe(100);
208+
209+
for (const challenge of challenges) {
210+
const decoded = Buffer.from(challenge, 'base64url');
211+
expect(decoded.byteLength).toBeGreaterThanOrEqual(32);
212+
}
213+
}, 10_000);
214+
215+
// WebAuthn §7.1 step 5 / §7.2 step 10: server must discard each challenge on first use
216+
// https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
217+
it('consumeRegistrationChallenge physically deletes the Redis key on first use', async () => {
218+
const uid = fakeUid();
219+
const challenge =
220+
await challengeManager.generateRegistrationChallenge(uid);
221+
222+
await challengeManager.consumeRegistrationChallenge(challenge, uid);
223+
224+
expect(await redis.keys('passkey:challenge:*')).toHaveLength(0);
225+
});
226+
227+
// WebAuthn §13.4.3: cross-ceremony attack prevention — ceremony type is part of
228+
// the Redis key, so a registration challenge cannot satisfy an authentication lookup.
229+
// Also verifies the failed lookup does not consume the challenge.
230+
// https://www.w3.org/TR/webauthn-3/#sctn-cryptographic-challenges
231+
it('a registration challenge is rejected when presented as an authentication challenge and remains unconsumed', async () => {
232+
const uid = fakeUid();
233+
const challenge =
234+
await challengeManager.generateRegistrationChallenge(uid);
235+
236+
expect(
237+
await challengeManager.consumeAuthenticationChallenge(challenge)
238+
).toBeNull();
239+
240+
const correct = await challengeManager.consumeRegistrationChallenge(
241+
challenge,
242+
uid
243+
);
244+
expect(correct).not.toBeNull();
245+
expect(correct?.type).toBe('registration');
246+
});
247+
});
248+
});

0 commit comments

Comments
 (0)