1- import { validateCertificatePath } from '../helpers/validateCertificatePath.ts' ;
2- import { convertCertBufferToPEM } from '../helpers/convertCertBufferToPEM.ts' ;
31import { 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' ;
114import { getLogger } from '../helpers/logging.ts' ;
12- import { convertPEMToBytes } from '../helpers/convertPEMToBytes.ts' ;
135import { fetch } from '../helpers/fetch.ts' ;
146import 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
209type 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
2623type 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
3133const 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