1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
|
import {
AuthenticationCredentialJSON,
AuthenticatorDevice,
CredentialDeviceType,
UserVerificationRequirement,
} from '@simplewebauthn/typescript-types';
import { decodeClientDataJSON } from '../helpers/decodeClientDataJSON';
import { toHash } from '../helpers/toHash';
import { verifySignature } from '../helpers/verifySignature';
import { parseAuthenticatorData } from '../helpers/parseAuthenticatorData';
import { parseBackupFlags } from '../helpers/parseBackupFlags';
import { AuthenticationExtensionsAuthenticatorOutputs } from '../helpers/decodeAuthenticatorExtensions';
import { matchExpectedRPID } from '../helpers/matchExpectedRPID';
import { isoUint8Array, isoBase64URL } from '../helpers/iso';
export type VerifyAuthenticationResponseOpts = {
credential: AuthenticationCredentialJSON;
expectedChallenge: string | ((challenge: string) => boolean);
expectedOrigin: string | string[];
expectedRPID: string | string[];
authenticator: AuthenticatorDevice;
requireUserVerification?: boolean;
advancedFIDOConfig?: {
userVerification?: UserVerificationRequirement;
};
};
/**
* Verify that the user has legitimately completed the login process
*
* **Options:**
*
* @param credential Authenticator credential returned by browser's `startAssertion()`
* @param expectedChallenge The base64url-encoded `options.challenge` returned by
* `generateAssertionOptions()`
* @param expectedOrigin Website URL (or array of URLs) that the registration should have occurred on
* @param expectedRPID RP ID (or array of IDs) that was specified in the registration options
* @param authenticator An internal {@link AuthenticatorDevice} matching the credential's ID
* @param requireUserVerification (Optional) Enforce user verification by the authenticator
* (via PIN, fingerprint, etc...)
* @param advancedFIDOConfig (Optional) Options for satisfying more stringent FIDO RP feature
* requirements
* @param advancedFIDOConfig.userVerification (Optional) Enable alternative rules for evaluating the
* User Presence and User Verified flags in authenticator data: UV (and UP) flags are optional
* unless this value is `"required"`
*/
export async function verifyAuthenticationResponse(
options: VerifyAuthenticationResponseOpts,
): Promise<VerifiedAuthenticationResponse> {
const {
credential,
expectedChallenge,
expectedOrigin,
expectedRPID,
authenticator,
requireUserVerification,
advancedFIDOConfig,
} = options;
const { id, rawId, type: credentialType, response } = credential;
// Ensure credential specified an ID
if (!id) {
throw new Error('Missing credential ID');
}
// Ensure ID is base64url-encoded
if (id !== rawId) {
throw new Error('Credential ID was not base64url-encoded');
}
// Make sure credential type is public-key
if (credentialType !== 'public-key') {
throw new Error(`Unexpected credential type ${credentialType}, expected "public-key"`);
}
if (!response) {
throw new Error('Credential missing response');
}
if (typeof response?.clientDataJSON !== 'string') {
throw new Error('Credential response clientDataJSON was not a string');
}
const clientDataJSON = decodeClientDataJSON(response.clientDataJSON);
const { type, origin, challenge, tokenBinding } = clientDataJSON;
// Make sure we're handling an authentication
if (type !== 'webauthn.get') {
throw new Error(`Unexpected authentication response type: ${type}`);
}
// Ensure the device provided the challenge we gave it
if (typeof expectedChallenge === 'function') {
if (!expectedChallenge(challenge)) {
throw new Error(
`Custom challenge verifier returned false for registration response challenge "${challenge}"`,
);
}
} else if (challenge !== expectedChallenge) {
throw new Error(
`Unexpected authentication response challenge "${challenge}", expected "${expectedChallenge}"`,
);
}
// Check that the origin is our site
if (Array.isArray(expectedOrigin)) {
if (!expectedOrigin.includes(origin)) {
const joinedExpectedOrigin = expectedOrigin.join(', ');
throw new Error(
`Unexpected authentication response origin "${origin}", expected one of: ${joinedExpectedOrigin}`,
);
}
} else {
if (origin !== expectedOrigin) {
throw new Error(
`Unexpected authentication response origin "${origin}", expected "${expectedOrigin}"`,
);
}
}
if (!isoBase64URL.isBase64url(response.authenticatorData)) {
throw new Error('Credential response authenticatorData was not a base64url string');
}
if (!isoBase64URL.isBase64url(response.signature)) {
throw new Error('Credential response signature was not a base64url string');
}
if (response.userHandle && typeof response.userHandle !== 'string') {
throw new Error('Credential response userHandle was not a string');
}
if (tokenBinding) {
if (typeof tokenBinding !== 'object') {
throw new Error('ClientDataJSON tokenBinding was not an object');
}
if (['present', 'supported', 'notSupported'].indexOf(tokenBinding.status) < 0) {
throw new Error(`Unexpected tokenBinding status ${tokenBinding.status}`);
}
}
const authDataBuffer = isoBase64URL.toBuffer(response.authenticatorData);
const parsedAuthData = parseAuthenticatorData(authDataBuffer);
const { rpIdHash, flags, counter, extensionsData } = parsedAuthData;
// Make sure the response's RP ID is ours
let expectedRPIDs: string[] = [];
if (typeof expectedRPID === 'string') {
expectedRPIDs = [expectedRPID];
} else {
expectedRPIDs = expectedRPID;
}
await matchExpectedRPID(rpIdHash, expectedRPIDs);
if (advancedFIDOConfig !== undefined) {
const { userVerification: fidoUserVerification } = advancedFIDOConfig;
/**
* Use FIDO Conformance-defined rules for verifying UP and UV flags
*/
if (fidoUserVerification === 'required') {
// Require `flags.uv` be true (implies `flags.up` is true)
if (!flags.uv) {
throw new Error('User verification required, but user could not be verified');
}
} else if (fidoUserVerification === 'preferred' || fidoUserVerification === 'discouraged') {
// Ignore `flags.uv`
}
} else {
/**
* Use WebAuthn spec-defined rules for verifying UP and UV flags
*/
// WebAuthn only requires the user presence flag be true
if (!flags.up) {
throw new Error('User not present during authentication');
}
// Enforce user verification if required
if (requireUserVerification && !flags.uv) {
throw new Error('User verification required, but user could not be verified');
}
}
const clientDataHash = await toHash(isoBase64URL.toBuffer(response.clientDataJSON));
const signatureBase = isoUint8Array.concat([authDataBuffer, clientDataHash]);
const signature = isoBase64URL.toBuffer(response.signature);
if ((counter > 0 || authenticator.counter > 0) && counter <= authenticator.counter) {
// Error out when the counter in the DB is greater than or equal to the counter in the
// dataStruct. It's related to how the authenticator maintains the number of times its been
// used for this client. If this happens, then someone's somehow increased the counter
// on the device without going through this site
throw new Error(
`Response counter value ${counter} was lower than expected ${authenticator.counter}`,
);
}
const { credentialDeviceType, credentialBackedUp } = parseBackupFlags(flags);
const toReturn: VerifiedAuthenticationResponse = {
verified: await verifySignature({
signature,
signatureBase,
credentialPublicKey: authenticator.credentialPublicKey,
}),
authenticationInfo: {
newCounter: counter,
credentialID: authenticator.credentialID,
userVerified: flags.uv,
credentialDeviceType,
credentialBackedUp,
authenticatorExtensionResults: extensionsData,
},
};
return toReturn;
}
/**
* Result of authentication verification
*
* @param verified If the authentication response could be verified
* @param authenticationInfo.credentialID The ID of the authenticator used during authentication.
* Should be used to identify which DB authenticator entry needs its `counter` updated to the value
* below
* @param authenticationInfo.newCounter The number of times the authenticator identified above
* reported it has been used. **Should be kept in a DB for later reference to help prevent replay
* attacks!**
* @param authenticationInfo.credentialDeviceType Whether this is a single-device or multi-device
* credential. **Should be kept in a DB for later reference!**
* @param authenticationInfo.credentialBackedUp Whether or not the multi-device credential has been
* backed up. Always `false` for single-device credentials. **Should be kept in a DB for later
* reference!**
* @param authenticationInfo?.authenticatorExtensionResults The authenticator extensions returned
* by the browser
*/
export type VerifiedAuthenticationResponse = {
verified: boolean;
authenticationInfo: {
credentialID: Uint8Array;
newCounter: number;
userVerified: boolean;
credentialDeviceType: CredentialDeviceType;
credentialBackedUp: boolean;
authenticatorExtensionResults?: AuthenticationExtensionsAuthenticatorOutputs;
};
};
|