Skip to content

Commit d9e7dd1

Browse files
authored
Merge pull request #752 from MasterKale/feat/support-offline-metadata-blobs
Support loading offline MDS blobs in MetadataService
2 parents 4874f9a + 6bf921e commit d9e7dd1

5 files changed

Lines changed: 181 additions & 82 deletions

File tree

packages/server/src/authentication/verifyAuthenticationResponse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export type VerifyAuthenticationResponseOpts = Parameters<typeof verifyAuthentic
2424
*
2525
* **Options:**
2626
*
27-
* @param response - Response returned by **@simplewebauthn/browser**'s `startAssertion()`
27+
* @param response - Response returned by **@simplewebauthn/browser**'s `startAuthentication()`
2828
* @param expectedChallenge - The base64url-encoded `options.challenge` returned by `generateAuthenticationOptions()`
2929
* @param expectedOrigin - Website URL (or array of URLs) that the registration should have occurred on
3030
* @param expectedRPID - RP ID (or array of IDs) that was specified in the registration options

packages/server/src/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ export * from './toHash.ts';
1313
export * from './validateCertificatePath.ts';
1414
export * from './verifySignature.ts';
1515
export * from './iso/index.ts';
16+
export * from '../metadata/verifyMDSBlob.ts';
1617
export * as cose from './cose.ts';

packages/server/src/metadata/verifyJWT.test.ts

Lines changed: 25 additions & 13 deletions
Large diffs are not rendered by default.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { MDSJWTHeader, MDSJWTPayload, MetadataStatement } from './mdsTypes.ts';
2+
import { parseJWT } from './parseJWT.ts';
3+
import { verifyJWT } from './verifyJWT.ts';
4+
import { validateCertificatePath } from '../helpers/validateCertificatePath.ts';
5+
import { convertCertBufferToPEM } from '../helpers/convertCertBufferToPEM.ts';
6+
import { convertPEMToBytes } from '../helpers/convertPEMToBytes.ts';
7+
import { SettingsService } from '../services/settingsService.ts';
8+
9+
/**
10+
* Perform authenticity and integrity verification of a
11+
* [FIDO Metadata Service (MDS)](https://fidoalliance.org/metadata/)-compatible blob, and then
12+
* extract the FIDO2 metadata statements included within. This method will make network requests
13+
* for things like CRL checks.
14+
*
15+
* @param blob - A JWT downloaded from an MDS server (e.g. https://mds3.fidoalliance.org)
16+
*/
17+
export async function verifyMDSBlob(blob: string): Promise<{
18+
/** MetadataStatement entries within the verified blob */
19+
statements: MetadataStatement[];
20+
/** A JS `Date` instance of the verified blob's `payload.nextUpdate` string */
21+
parsedNextUpdate: Date;
22+
/** The verified blob's `payload` value */
23+
payload: MDSJWTPayload;
24+
}> {
25+
// Parse the JWT
26+
const parsedJWT = parseJWT<MDSJWTHeader, MDSJWTPayload>(blob);
27+
const header = parsedJWT[0];
28+
const payload = parsedJWT[1];
29+
30+
const headerCertsPEM = header.x5c.map(convertCertBufferToPEM);
31+
try {
32+
// Validate the certificate chain
33+
const rootCerts = SettingsService.getRootCertificates({
34+
identifier: 'mds',
35+
});
36+
await validateCertificatePath(headerCertsPEM, rootCerts);
37+
} catch (error) {
38+
const _error: Error = error as Error;
39+
// From FIDO MDS docs: "ignore the file if the chain cannot be verified or if one of the
40+
// chain certificates is revoked"
41+
throw new Error(
42+
'BLOB certificate path could not be validated',
43+
{ cause: _error },
44+
);
45+
}
46+
47+
// Verify the BLOB JWT signature
48+
const leafCert = headerCertsPEM[0];
49+
const verified = await verifyJWT(blob, convertPEMToBytes(leafCert));
50+
51+
if (!verified) {
52+
// From FIDO MDS docs: "The FIDO Server SHOULD ignore the file if the signature is invalid."
53+
throw new Error('BLOB signature could not be verified');
54+
}
55+
56+
// Cache statements for FIDO2 devices
57+
const statements: MetadataStatement[] = [];
58+
for (const entry of payload.entries) {
59+
// Only cache entries with an `aaguid`
60+
if (entry.aaguid && entry.metadataStatement) {
61+
statements.push(entry.metadataStatement);
62+
}
63+
}
64+
65+
// Convert the nextUpdate property into a Date so we can determine when to re-download
66+
const [year, month, day] = payload.nextUpdate.split('-');
67+
const parsedNextUpdate = new Date(
68+
parseInt(year, 10),
69+
// Months need to be zero-indexed
70+
parseInt(month, 10) - 1,
71+
parseInt(day, 10),
72+
);
73+
74+
return {
75+
statements,
76+
parsedNextUpdate,
77+
payload,
78+
};
79+
}

packages/server/src/services/metadataService.ts

Lines changed: 75 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
1-
import { validateCertificatePath } from '../helpers/validateCertificatePath.ts';
2-
import { convertCertBufferToPEM } from '../helpers/convertCertBufferToPEM.ts';
31
import { convertAAGUIDToString } from '../helpers/convertAAGUIDToString.ts';
4-
import type {
5-
MDSJWTHeader,
6-
MDSJWTPayload,
7-
MetadataBLOBPayloadEntry,
8-
MetadataStatement,
9-
} from '../metadata/mdsTypes.ts';
10-
import { SettingsService } from '../services/settingsService.ts';
2+
import type { MetadataBLOBPayloadEntry, MetadataStatement } from '../metadata/mdsTypes.ts';
3+
import { verifyMDSBlob } from '../metadata/verifyMDSBlob.ts';
114
import { getLogger } from '../helpers/logging.ts';
12-
import { convertPEMToBytes } from '../helpers/convertPEMToBytes.ts';
135
import { fetch } from '../helpers/fetch.ts';
146
import type { Uint8Array_ } from '../types/index.ts';
157

16-
import { parseJWT } from '../metadata/parseJWT.ts';
17-
import { verifyJWT } from '../metadata/verifyJWT.ts';
18-
198
// Cached MDS APIs from which BLOBs are downloaded
209
type CachedMDS = {
2110
url: string;
2211
no: number;
2312
nextUpdate: Date;
2413
};
14+
/**
15+
* An instance of `CachedMDS` that will not trigger attempts to refresh the associated entry's blob
16+
*/
17+
const NonRefreshingMDS: CachedMDS = {
18+
url: '',
19+
no: 0,
20+
nextUpdate: new Date(0),
21+
} as const;
2522

2623
type CachedBLOBEntry = {
24+
/** The entry in the MDS blob */
2725
entry: MetadataBLOBPayloadEntry;
28-
url: string;
26+
/**
27+
* The MDS server the blob containing this entry was downloaded from. An empty URL will skip
28+
* attempts to refresh this entry
29+
*/
30+
url: CachedMDS['url'];
2931
};
3032

3133
const defaultURLMDS = 'https://mds.fidoalliance.org/'; // v3
@@ -52,7 +54,8 @@ interface MetadataService {
5254
*
5355
* @param opts.mdsServers An array of URLs to FIDO Alliance Metadata Service
5456
* (version 3.0)-compatible servers. Defaults to the official FIDO MDS server
55-
* @param opts.statements An array of local metadata statements
57+
* @param opts.statements An array of local metadata statements. Statements will be loaded but
58+
* not refreshed
5659
* @param opts.verificationMode How MetadataService will handle unregistered AAGUIDs. Defaults to
5760
* `"strict"` which throws errors during registration response verification when an
5861
* unregistered AAGUID is encountered. Set to `"permissive"` to allow registration by
@@ -91,11 +94,17 @@ export class BaseMetadataService implements MetadataService {
9194
verificationMode?: VerificationMode;
9295
} = {},
9396
): Promise<void> {
97+
// Reset statement cache
98+
this.statementCache = {};
99+
94100
const { mdsServers = [defaultURLMDS], statements, verificationMode } = opts;
95101

96102
this.setState(SERVICE_STATE.REFRESHING);
97103

98-
// If metadata statements are provided, load them into the cache first
104+
/**
105+
* If metadata statements are provided, load them into the cache first. These statements will
106+
* not be refreshed when a stale one is detected.
107+
*/
99108
if (statements?.length) {
100109
let statementsAdded = 0;
101110

@@ -108,7 +117,7 @@ export class BaseMetadataService implements MetadataService {
108117
statusReports: [],
109118
timeOfLastStatusChange: '1970-01-01',
110119
},
111-
url: '',
120+
url: NonRefreshingMDS.url,
112121
};
113122

114123
statementsAdded += 1;
@@ -118,19 +127,26 @@ export class BaseMetadataService implements MetadataService {
118127
log(`Cached ${statementsAdded} local statements`);
119128
}
120129

121-
// If MDS servers are provided, then process them and add their statements to the cache
130+
/**
131+
* If MDS servers are provided, then download blobs from them, verify them, and then add their
132+
* entries to the cache. Blobs loaded in this way will be refreshed when a stale entry within is
133+
* detected.
134+
*/
122135
if (mdsServers?.length) {
123136
// Get a current count so we know how many new statements we've added from MDS servers
124137
const currentCacheCount = Object.keys(this.statementCache).length;
125138
let numServers = mdsServers.length;
126139

127140
for (const url of mdsServers) {
128141
try {
129-
await this.downloadBlob({
142+
const cachedMDS: CachedMDS = {
130143
url,
131144
no: 0,
132145
nextUpdate: new Date(0),
133-
});
146+
};
147+
148+
const blob = await this.downloadBlob(cachedMDS);
149+
await this.verifyBlob(blob, cachedMDS);
134150
} catch (err) {
135151
// Notify of the error and move on
136152
log(`Could not download BLOB from ${url}:`, err);
@@ -191,7 +207,8 @@ export class BaseMetadataService implements MetadataService {
191207
if (now > mds.nextUpdate) {
192208
try {
193209
this.setState(SERVICE_STATE.REFRESHING);
194-
await this.downloadBlob(mds);
210+
const blob = await this.downloadBlob(mds);
211+
await this.verifyBlob(blob, mds);
195212
} finally {
196213
this.setState(SERVICE_STATE.READY);
197214
}
@@ -219,51 +236,32 @@ export class BaseMetadataService implements MetadataService {
219236
/**
220237
* Download and process the latest BLOB from MDS
221238
*/
222-
private async downloadBlob(mds: CachedMDS) {
223-
const { url, no } = mds;
239+
private async downloadBlob(cachedMDS: CachedMDS) {
240+
const { url } = cachedMDS;
241+
224242
// Get latest "BLOB" (FIDO's terminology, not mine)
225243
const resp = await fetch(url);
226244
const data = await resp.text();
227245

228-
// Parse the JWT
229-
const parsedJWT = parseJWT<MDSJWTHeader, MDSJWTPayload>(data);
230-
const header = parsedJWT[0];
231-
const payload = parsedJWT[1];
246+
return data;
247+
}
248+
249+
/**
250+
* Verify and process the MDS metadata blob
251+
*/
252+
private async verifyBlob(blob: string, cachedMDS: CachedMDS) {
253+
const { url, no } = cachedMDS;
254+
255+
const { payload, parsedNextUpdate } = await verifyMDSBlob(blob);
232256

233257
if (payload.no <= no) {
234258
// From FIDO MDS docs: "also ignore the file if its number (no) is less or equal to the
235259
// number of the last BLOB cached locally."
236260
throw new Error(
237-
`Latest BLOB no. "${payload.no}" is not greater than previous ${no}`,
238-
);
239-
}
240-
241-
const headerCertsPEM = header.x5c.map(convertCertBufferToPEM);
242-
try {
243-
// Validate the certificate chain
244-
const rootCerts = SettingsService.getRootCertificates({
245-
identifier: 'mds',
246-
});
247-
await validateCertificatePath(headerCertsPEM, rootCerts);
248-
} catch (error) {
249-
const _error: Error = error as Error;
250-
// From FIDO MDS docs: "ignore the file if the chain cannot be verified or if one of the
251-
// chain certificates is revoked"
252-
throw new Error(
253-
'BLOB certificate path could not be validated',
254-
{ cause: _error },
261+
`Latest BLOB no. ${payload.no} is not greater than previous no. ${no}`,
255262
);
256263
}
257264

258-
// Verify the BLOB JWT signature
259-
const leafCert = headerCertsPEM[0];
260-
const verified = await verifyJWT(data, convertPEMToBytes(leafCert));
261-
262-
if (!verified) {
263-
// From FIDO MDS docs: "The FIDO Server SHOULD ignore the file if the signature is invalid."
264-
throw new Error('BLOB signature could not be verified');
265-
}
266-
267265
// Cache statements for FIDO2 devices
268266
for (const entry of payload.entries) {
269267
// Only cache entries with an `aaguid`
@@ -272,20 +270,29 @@ export class BaseMetadataService implements MetadataService {
272270
}
273271
}
274272

275-
// Remember info about the server so we can refresh later
276-
const [year, month, day] = payload.nextUpdate.split('-');
277-
this.mdsCache[url] = {
278-
...mds,
279-
// Store the payload `no` to make sure we're getting the next BLOB in the sequence
280-
no: payload.no,
281-
// Convert the nextUpdate property into a Date so we can determine when to re-download
282-
nextUpdate: new Date(
283-
parseInt(year, 10),
284-
// Months need to be zero-indexed
285-
parseInt(month, 10) - 1,
286-
parseInt(day, 10),
287-
),
288-
};
273+
if (url) {
274+
// Remember info about the server so we can refresh later
275+
this.mdsCache[url] = {
276+
...cachedMDS,
277+
// Store the payload `no` to make sure we're getting the next BLOB in the sequence
278+
no: payload.no,
279+
// Remember when we need to refresh this blob
280+
nextUpdate: parsedNextUpdate,
281+
};
282+
} else {
283+
/**
284+
* This blob will not be refreshed, but we should still alert if the blob's `nextUpdate` is
285+
* in the past
286+
*/
287+
if (parsedNextUpdate < new Date()) {
288+
// TODO (Feb 2026): It'd be more actionable for devs if a specific error was raised here,
289+
// then this message was logged higher up when it can include the array index of the stale
290+
// blob.
291+
log(
292+
`⚠️ This MDS blob (serial: ${payload.no}) contains stale data as of ${parsedNextUpdate.toISOString()}. Please consider re-initializing MetadataService with a newer MDS blob.`,
293+
);
294+
}
295+
}
289296
}
290297

291298
/**

0 commit comments

Comments
 (0)