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
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
|
import { validateCertificatePath } from '../helpers/validateCertificatePath.ts';
import { convertCertBufferToPEM } from '../helpers/convertCertBufferToPEM.ts';
import { convertAAGUIDToString } from '../helpers/convertAAGUIDToString.ts';
import type {
MDSJWTHeader,
MDSJWTPayload,
MetadataBLOBPayloadEntry,
MetadataStatement,
} from '../metadata/mdsTypes.ts';
import { SettingsService } from '../services/settingsService.ts';
import { getLogger } from '../helpers/logging.ts';
import { convertPEMToBytes } from '../helpers/convertPEMToBytes.ts';
import { fetch } from '../helpers/fetch.ts';
import { parseJWT } from '../metadata/parseJWT.ts';
import { verifyJWT } from '../metadata/verifyJWT.ts';
// Cached MDS APIs from which BLOBs are downloaded
type CachedMDS = {
url: string;
no: number;
nextUpdate: Date;
};
type CachedBLOBEntry = {
entry: MetadataBLOBPayloadEntry;
url: string;
};
const defaultURLMDS = 'https://mds.fidoalliance.org/'; // v3
enum SERVICE_STATE {
DISABLED,
REFRESHING,
READY,
}
// Allow MetadataService to accommodate unregistered AAGUIDs ("permissive"), or only allow
// registered AAGUIDs ("strict"). Currently primarily impacts how `getStatement()` operates
type VerificationMode = 'permissive' | 'strict';
const log = getLogger('MetadataService');
/**
* A basic service for coordinating interactions with the FIDO Metadata Service. This includes BLOB
* download and parsing, and on-demand requesting and caching of individual metadata statements.
*
* https://fidoalliance.org/metadata/
*/
export class BaseMetadataService {
private mdsCache: { [url: string]: CachedMDS } = {};
private statementCache: { [aaguid: string]: CachedBLOBEntry } = {};
private state: SERVICE_STATE = SERVICE_STATE.DISABLED;
private verificationMode: VerificationMode = 'strict';
/**
* Prepare the service to handle remote MDS servers and/or cache local metadata statements.
*
* **Options:**
*
* @param opts.mdsServers An array of URLs to FIDO Alliance Metadata Service
* (version 3.0)-compatible servers. Defaults to the official FIDO MDS server
* @param opts.statements An array of local metadata statements
* @param opts.verificationMode How MetadataService will handle unregistered AAGUIDs. Defaults to
* `"strict"` which throws errors during registration response verification when an
* unregistered AAGUID is encountered. Set to `"permissive"` to allow registration by
* authenticators with unregistered AAGUIDs
*/
async initialize(
opts: {
mdsServers?: string[];
statements?: MetadataStatement[];
verificationMode?: VerificationMode;
} = {},
): Promise<void> {
const { mdsServers = [defaultURLMDS], statements, verificationMode } = opts;
this.setState(SERVICE_STATE.REFRESHING);
// If metadata statements are provided, load them into the cache first
if (statements?.length) {
let statementsAdded = 0;
statements.forEach((statement) => {
// Only cache statements that are for FIDO2-compatible authenticators
if (statement.aaguid) {
this.statementCache[statement.aaguid] = {
entry: {
metadataStatement: statement,
statusReports: [],
timeOfLastStatusChange: '1970-01-01',
},
url: '',
};
statementsAdded += 1;
}
});
log(`Cached ${statementsAdded} local statements`);
}
// If MDS servers are provided, then process them and add their statements to the cache
if (mdsServers?.length) {
// Get a current count so we know how many new statements we've added from MDS servers
const currentCacheCount = Object.keys(this.statementCache).length;
let numServers = mdsServers.length;
for (const url of mdsServers) {
try {
await this.downloadBlob({
url,
no: 0,
nextUpdate: new Date(0),
});
} catch (err) {
// Notify of the error and move on
log(`Could not download BLOB from ${url}:`, err);
numServers -= 1;
}
}
// Calculate the difference to get the total number of new statements we successfully added
const newCacheCount = Object.keys(this.statementCache).length;
const cacheDiff = newCacheCount - currentCacheCount;
log(
`Cached ${cacheDiff} statements from ${numServers} metadata server(s)`,
);
}
if (verificationMode) {
this.verificationMode = verificationMode;
}
this.setState(SERVICE_STATE.READY);
}
/**
* Get a metadata statement for a given AAGUID.
*
* This method will coordinate updating the cache as per the `nextUpdate` property in the initial
* BLOB download.
*/
async getStatement(
aaguid: string | Uint8Array,
): Promise<MetadataStatement | undefined> {
if (this.state === SERVICE_STATE.DISABLED) {
return;
}
if (!aaguid) {
return;
}
if (aaguid instanceof Uint8Array) {
aaguid = convertAAGUIDToString(aaguid);
}
// If a cache refresh is in progress then pause this until the service is ready
await this.pauseUntilReady();
// Try to grab a cached statement
const cachedStatement = this.statementCache[aaguid];
if (!cachedStatement) {
if (this.verificationMode === 'strict') {
// FIDO conformance requires RP's to only support registered AAGUID's
throw new Error(`No metadata statement found for aaguid "${aaguid}"`);
}
// Allow registration verification to continue without using metadata
return;
}
// If the statement points to an MDS API, check the MDS' nextUpdate to see if we need to refresh
if (cachedStatement.url) {
const mds = this.mdsCache[cachedStatement.url];
const now = new Date();
if (now > mds.nextUpdate) {
try {
this.setState(SERVICE_STATE.REFRESHING);
await this.downloadBlob(mds);
} finally {
this.setState(SERVICE_STATE.READY);
}
}
}
const { entry } = cachedStatement;
// Check to see if the this aaguid has a status report with a "compromised" status
for (const report of entry.statusReports) {
const { status } = report;
if (
status === 'USER_VERIFICATION_BYPASS' ||
status === 'ATTESTATION_KEY_COMPROMISE' ||
status === 'USER_KEY_REMOTE_COMPROMISE' ||
status === 'USER_KEY_PHYSICAL_COMPROMISE'
) {
throw new Error(`Detected compromised aaguid "${aaguid}"`);
}
}
return entry.metadataStatement;
}
/**
* Download and process the latest BLOB from MDS
*/
private async downloadBlob(mds: CachedMDS) {
const { url, no } = mds;
// Get latest "BLOB" (FIDO's terminology, not mine)
const resp = await fetch(url);
const data = await resp.text();
// Parse the JWT
const parsedJWT = parseJWT<MDSJWTHeader, MDSJWTPayload>(data);
const header = parsedJWT[0];
const payload = parsedJWT[1];
if (payload.no <= no) {
// From FIDO MDS docs: "also ignore the file if its number (no) is less or equal to the
// number of the last BLOB cached locally."
throw new Error(
`Latest BLOB no. "${payload.no}" is not greater than previous ${no}`,
);
}
const headerCertsPEM = header.x5c.map(convertCertBufferToPEM);
try {
// Validate the certificate chain
const rootCerts = SettingsService.getRootCertificates({
identifier: 'mds',
});
await validateCertificatePath(headerCertsPEM, rootCerts);
} catch (error) {
const _error: Error = error as Error;
// From FIDO MDS docs: "ignore the file if the chain cannot be verified or if one of the
// chain certificates is revoked"
throw new Error(
`BLOB certificate path could not be validated: ${_error.message}`,
);
}
// Verify the BLOB JWT signature
const leafCert = headerCertsPEM[0];
const verified = await verifyJWT(data, convertPEMToBytes(leafCert));
if (!verified) {
// From FIDO MDS docs: "The FIDO Server SHOULD ignore the file if the signature is invalid."
throw new Error('BLOB signature could not be verified');
}
// Cache statements for FIDO2 devices
for (const entry of payload.entries) {
// Only cache entries with an `aaguid`
if (entry.aaguid) {
this.statementCache[entry.aaguid] = { entry, url };
}
}
// Remember info about the server so we can refresh later
const [year, month, day] = payload.nextUpdate.split('-');
this.mdsCache[url] = {
...mds,
// Store the payload `no` to make sure we're getting the next BLOB in the sequence
no: payload.no,
// Convert the nextUpdate property into a Date so we can determine when to re-download
nextUpdate: new Date(
parseInt(year, 10),
// Months need to be zero-indexed
parseInt(month, 10) - 1,
parseInt(day, 10),
),
};
}
/**
* A helper method to pause execution until the service is ready
*/
private pauseUntilReady(): Promise<void> {
if (this.state === SERVICE_STATE.READY) {
return new Promise((resolve) => {
resolve();
});
}
// State isn't ready, so set up polling
const readyPromise = new Promise<void>((resolve, reject) => {
const totalTimeoutMS = 70000;
const intervalMS = 100;
let iterations = totalTimeoutMS / intervalMS;
// Check service state every `intervalMS` milliseconds
const intervalID = globalThis.setInterval(() => {
if (iterations < 1) {
clearInterval(intervalID);
reject(
`State did not become ready in ${totalTimeoutMS / 1000} seconds`,
);
} else if (this.state === SERVICE_STATE.READY) {
clearInterval(intervalID);
resolve();
}
iterations -= 1;
}, intervalMS);
});
return readyPromise;
}
/**
* Report service status on change
*/
private setState(newState: SERVICE_STATE) {
this.state = newState;
if (newState === SERVICE_STATE.DISABLED) {
log('MetadataService is DISABLED');
} else if (newState === SERVICE_STATE.REFRESHING) {
log('MetadataService is REFRESHING');
} else if (newState === SERVICE_STATE.READY) {
log('MetadataService is READY');
}
}
}
// Export a service singleton
export const MetadataService = new BaseMetadataService();
|