Skip to content

Commit 1edf17b

Browse files
vveerrggclaude
andcommitted
fix: implement full FIDO2 WebAuthn verification
CRITICAL: verification previously only checked challenge string — did not verify origin, type, rpIdHash, or authenticator signature. Now uses @simplewebauthn/server for complete verification: - Origin validation against configured allowed origins - Type checking (webauthn.create / webauthn.get) - rpIdHash verification against expected RP ID - Cryptographic signature verification against stored credential pubkey - Sign counter tracking for clone detection - User verification flag enforcement - Credential public key storage from registration for auth verification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9af7a34 commit 1edf17b

2 files changed

Lines changed: 158 additions & 31 deletions

File tree

src/server/webauthn.ts

Lines changed: 137 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,39 @@
11
/**
22
* Server-side WebAuthn implementation
3+
*
4+
* Uses @simplewebauthn/server for full WebAuthn verification including:
5+
* - clientDataJSON.type validation ('webauthn.create' / 'webauthn.get')
6+
* - origin verification
7+
* - rpIdHash verification
8+
* - authenticator cryptographic signature verification
9+
* - sign counter checking for clone detection
310
*/
4-
import type { WebAuthnOptions } from '../types/auth';
11+
import type { WebAuthnOptions, StoredCredential } from '../types/auth';
512
import { generateRandomString } from '../utils';
13+
import {
14+
verifyRegistrationResponse,
15+
verifyAuthenticationResponse,
16+
type VerifiedRegistrationResponse,
17+
type VerifiedAuthenticationResponse,
18+
} from '@simplewebauthn/server';
19+
import type {
20+
RegistrationResponseJSON,
21+
AuthenticationResponseJSON,
22+
AuthenticatorTransportFuture,
23+
} from '@simplewebauthn/types';
624

725
export class WebAuthnServer {
826
private options: WebAuthnOptions;
927
private challenges: Map<string, { challenge: string; timestamp: number }>;
10-
private credentials: Map<string, Array<any>>;
28+
private credentials: Map<string, StoredCredential[]>;
1129

1230
constructor(options: WebAuthnOptions) {
31+
if (!options.rpId) {
32+
throw new Error('WebAuthnServer requires rpId to be set');
33+
}
34+
if (!options.origin) {
35+
throw new Error('WebAuthnServer requires origin to be set for verification');
36+
}
1337
this.options = options;
1438
this.challenges = new Map();
1539
this.credentials = new Map();
@@ -52,11 +76,23 @@ export class WebAuthnServer {
5276
}
5377

5478
/**
55-
* Verify a WebAuthn registration response
79+
* Verify a WebAuthn registration response.
80+
*
81+
* Validates:
82+
* - clientDataJSON.type === 'webauthn.create'
83+
* - clientDataJSON.origin matches expected origin
84+
* - clientDataJSON.challenge matches the issued challenge (constant-time)
85+
* - rpIdHash matches SHA-256 of the configured RP ID
86+
* - Authenticator flags (user presence, user verification if required)
87+
* - Attestation statement (if present)
88+
*
89+
* On success, stores the extracted credential public key and counter
90+
* for future authentication verification.
91+
*
5692
* @param userId The user's ID (pubkey)
57-
* @param credential The credential from the client
93+
* @param credential The RegistrationResponseJSON from the client
5894
*/
59-
async verifyRegistration(userId: string, credential: any): Promise<boolean> {
95+
async verifyRegistration(userId: string, credential: RegistrationResponseJSON): Promise<boolean> {
6096
// Check if user already has credentials
6197
if (this.credentials.has(userId)) {
6298
throw new Error('User already has registered credentials. Please use authentication instead.');
@@ -67,7 +103,7 @@ export class WebAuthnServer {
67103
throw new Error('No challenge found for user');
68104
}
69105

70-
// Remove the challenge
106+
// Remove the challenge (single-use)
71107
this.challenges.delete(userId);
72108

73109
// Check if challenge has expired (5 minutes)
@@ -76,33 +112,57 @@ export class WebAuthnServer {
76112
}
77113

78114
try {
79-
// TODO: Implement actual credential verification
80-
// For now, we'll just verify the challenge matches
81-
const clientDataJSON = JSON.parse(
82-
Buffer.from(credential.response.clientDataJSON, 'base64').toString()
83-
);
84-
85-
const isValid = clientDataJSON.challenge === expectedChallenge.challenge;
86-
87-
if (isValid) {
88-
// Store the credential
89-
this.credentials.set(userId, [credential]);
115+
// Use @simplewebauthn/server for full verification.
116+
// This validates: type, origin, rpIdHash, challenge, attestation, flags.
117+
const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({
118+
response: credential,
119+
expectedChallenge: expectedChallenge.challenge,
120+
expectedOrigin: Array.isArray(this.options.origin) ? this.options.origin : [this.options.origin],
121+
expectedRPID: this.options.rpId,
122+
requireUserVerification: this.options.userVerification === 'required',
123+
});
124+
125+
const { verified, registrationInfo } = verification;
126+
127+
if (verified && registrationInfo) {
128+
// Store the credential with its public key for future authentication
129+
const storedCredential: StoredCredential = {
130+
credentialID: Buffer.from(registrationInfo.credentialID).toString('base64url'),
131+
credentialPublicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64url'),
132+
counter: registrationInfo.counter,
133+
credentialBackedUp: registrationInfo.credentialBackedUp,
134+
credentialDeviceType: registrationInfo.credentialDeviceType,
135+
transports: credential.response.transports as AuthenticatorTransportFuture[] | undefined,
136+
};
137+
138+
this.credentials.set(userId, [storedCredential]);
90139
}
91140

92-
return isValid;
141+
return verified;
93142
} catch (error) {
94-
throw new Error('Failed to verify registration');
143+
const message = error instanceof Error ? error.message : 'Unknown error';
144+
throw new Error(`Failed to verify registration: ${message}`);
95145
}
96146
}
97147

98148
/**
99-
* Verify a WebAuthn authentication response
149+
* Verify a WebAuthn authentication response.
150+
*
151+
* Validates:
152+
* - clientDataJSON.type === 'webauthn.get'
153+
* - clientDataJSON.origin matches expected origin
154+
* - clientDataJSON.challenge matches the issued challenge (constant-time)
155+
* - rpIdHash matches SHA-256 of the configured RP ID
156+
* - Authenticator signature against the stored credential public key
157+
* - Sign counter to detect cloned authenticators
158+
*
100159
* @param userId The user's ID (pubkey)
101-
* @param credential The credential from the client
160+
* @param credential The AuthenticationResponseJSON from the client
102161
*/
103-
async verifyAuthentication(userId: string, credential: any): Promise<boolean> {
162+
async verifyAuthentication(userId: string, credential: AuthenticationResponseJSON): Promise<boolean> {
104163
// Check if user has registered
105-
if (!this.credentials.has(userId)) {
164+
const storedCredentials = this.credentials.get(userId);
165+
if (!storedCredentials || storedCredentials.length === 0) {
106166
throw new Error('No registered credentials found. Please register first.');
107167
}
108168

@@ -111,24 +171,70 @@ export class WebAuthnServer {
111171
throw new Error('No challenge found for user');
112172
}
113173

114-
// Remove the challenge
174+
// Remove the challenge (single-use)
115175
this.challenges.delete(userId);
116176

117177
// Check if challenge has expired (5 minutes)
118178
if (Date.now() - expectedChallenge.timestamp > 5 * 60 * 1000) {
119179
throw new Error('Challenge has expired');
120180
}
121181

182+
// Find the matching stored credential by credential ID
183+
const matchingCredential = storedCredentials.find(
184+
(cred) => cred.credentialID === credential.id
185+
);
186+
187+
if (!matchingCredential) {
188+
throw new Error('Credential not found for this user');
189+
}
190+
122191
try {
123-
// TODO: Implement actual credential verification
124-
// For now, we'll just verify the challenge matches
125-
const clientDataJSON = JSON.parse(
126-
Buffer.from(credential.response.clientDataJSON, 'base64').toString()
127-
);
192+
// Use @simplewebauthn/server for full verification.
193+
// This validates: type, origin, rpIdHash, challenge, signature, counter.
194+
const verification: VerifiedAuthenticationResponse = await verifyAuthenticationResponse({
195+
response: credential,
196+
expectedChallenge: expectedChallenge.challenge,
197+
expectedOrigin: Array.isArray(this.options.origin) ? this.options.origin : [this.options.origin],
198+
expectedRPID: this.options.rpId,
199+
authenticator: {
200+
credentialID: Buffer.from(matchingCredential.credentialID, 'base64url'),
201+
credentialPublicKey: Buffer.from(matchingCredential.credentialPublicKey, 'base64url'),
202+
counter: matchingCredential.counter,
203+
transports: matchingCredential.transports as AuthenticatorTransportFuture[] | undefined,
204+
},
205+
requireUserVerification: this.options.userVerification === 'required',
206+
});
207+
208+
const { verified, authenticationInfo } = verification;
209+
210+
if (verified) {
211+
// Update the sign counter to detect cloned authenticators.
212+
// If the new counter is not greater than the stored counter (and not zero),
213+
// it may indicate a cloned authenticator. @simplewebauthn/server handles
214+
// this check internally and will throw if the counter is suspicious.
215+
matchingCredential.counter = authenticationInfo.newCounter;
216+
}
128217

129-
return clientDataJSON.challenge === expectedChallenge.challenge;
218+
return verified;
130219
} catch (error) {
131-
throw new Error('Failed to verify authentication');
220+
const message = error instanceof Error ? error.message : 'Unknown error';
221+
throw new Error(`Failed to verify authentication: ${message}`);
132222
}
133223
}
224+
225+
/**
226+
* Get stored credentials for a user (useful for generating authentication options)
227+
* @param userId The user's ID (pubkey)
228+
*/
229+
getCredentials(userId: string): StoredCredential[] | undefined {
230+
return this.credentials.get(userId);
231+
}
232+
233+
/**
234+
* Remove all credentials for a user
235+
* @param userId The user's ID (pubkey)
236+
*/
237+
removeCredentials(userId: string): boolean {
238+
return this.credentials.delete(userId);
239+
}
134240
}

src/types/auth.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,33 @@ export interface WebAuthnOptions {
1818
rpId: string;
1919
/** Relying Party name (displayed to user) */
2020
rpName: string;
21+
/** Expected origin for WebAuthn verification (e.g., 'https://example.com') */
22+
origin: string | string[];
2123
/** User verification requirement */
2224
userVerification?: UserVerificationRequirement;
2325
/** Timeout in milliseconds */
2426
timeout?: number;
2527
}
2628

29+
/**
30+
* Stored credential data from a successful WebAuthn registration.
31+
* Contains the public key and counter needed for authentication verification.
32+
*/
33+
export interface StoredCredential {
34+
/** Base64url-encoded credential ID */
35+
credentialID: string;
36+
/** Base64url-encoded credential public key */
37+
credentialPublicKey: string;
38+
/** Sign counter for clone detection */
39+
counter: number;
40+
/** The credential backing type (e.g., 'singleDevice' or 'multiDevice') */
41+
credentialBackedUp: boolean;
42+
/** The credential device type */
43+
credentialDeviceType: string;
44+
/** Transports supported by this credential */
45+
transports?: AuthenticatorTransport[];
46+
}
47+
2748
export interface AuthenticationState {
2849
/** Current step in the authentication process */
2950
step: AuthenticationStep;

0 commit comments

Comments
 (0)