Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type VerifyAuthenticationResponseOpts = Parameters<typeof verifyAuthentic
*
* **Options:**
*
* @param response - Response returned by **@simplewebauthn/browser**'s `startAssertion()`
* @param response - Response returned by **@simplewebauthn/browser**'s `startAuthentication()`
* @param expectedChallenge - The base64url-encoded `options.challenge` returned by `generateAuthenticationOptions()`
* @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
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export * from './toHash.ts';
export * from './validateCertificatePath.ts';
export * from './verifySignature.ts';
export * from './iso/index.ts';
export * from '../metadata/verifyMDSBlob.ts';
export * as cose from './cose.ts';
38 changes: 25 additions & 13 deletions packages/server/src/metadata/verifyJWT.test.ts

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions packages/server/src/metadata/verifyMDSBlob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { MDSJWTHeader, MDSJWTPayload, MetadataStatement } from './mdsTypes.ts';
import { parseJWT } from './parseJWT.ts';
import { verifyJWT } from './verifyJWT.ts';
import { validateCertificatePath } from '../helpers/validateCertificatePath.ts';
import { convertCertBufferToPEM } from '../helpers/convertCertBufferToPEM.ts';
import { convertPEMToBytes } from '../helpers/convertPEMToBytes.ts';
import { SettingsService } from '../services/settingsService.ts';

/**
* Perform authenticity and integrity verification of a
* [FIDO Metadata Service (MDS)](https://fidoalliance.org/metadata/)-compatible blob, and then
* extract the FIDO2 metadata statements included within. This method will make network requests
* for things like CRL checks.
*
* @param blob - A JWT downloaded from an MDS server (e.g. https://mds3.fidoalliance.org)
*/
export async function verifyMDSBlob(blob: string): Promise<{
/** MetadataStatement entries within the verified blob */
statements: MetadataStatement[];
/** A JS `Date` instance of the verified blob's `payload.nextUpdate` string */
parsedNextUpdate: Date;
/** The verified blob's `payload` value */
payload: MDSJWTPayload;
}> {
// Parse the JWT
const parsedJWT = parseJWT<MDSJWTHeader, MDSJWTPayload>(blob);
const header = parsedJWT[0];
const payload = parsedJWT[1];

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',
{ cause: _error },
);
}

// Verify the BLOB JWT signature
const leafCert = headerCertsPEM[0];
const verified = await verifyJWT(blob, 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
const statements: MetadataStatement[] = [];
for (const entry of payload.entries) {
// Only cache entries with an `aaguid`
if (entry.aaguid && entry.metadataStatement) {
statements.push(entry.metadataStatement);
}
}

// Convert the nextUpdate property into a Date so we can determine when to re-download
const [year, month, day] = payload.nextUpdate.split('-');
const parsedNextUpdate = new Date(
parseInt(year, 10),
// Months need to be zero-indexed
parseInt(month, 10) - 1,
parseInt(day, 10),
);

return {
statements,
parsedNextUpdate,
payload,
};
}
143 changes: 75 additions & 68 deletions packages/server/src/services/metadataService.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
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 type { MetadataBLOBPayloadEntry, MetadataStatement } from '../metadata/mdsTypes.ts';
import { verifyMDSBlob } from '../metadata/verifyMDSBlob.ts';
import { getLogger } from '../helpers/logging.ts';
import { convertPEMToBytes } from '../helpers/convertPEMToBytes.ts';
import { fetch } from '../helpers/fetch.ts';
import type { Uint8Array_ } from '../types/index.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;
};
/**
* An instance of `CachedMDS` that will not trigger attempts to refresh the associated entry's blob
*/
const NonRefreshingMDS: CachedMDS = {
url: '',
no: 0,
nextUpdate: new Date(0),
} as const;

type CachedBLOBEntry = {
/** The entry in the MDS blob */
entry: MetadataBLOBPayloadEntry;
url: string;
/**
* The MDS server the blob containing this entry was downloaded from. An empty URL will skip
* attempts to refresh this entry
*/
url: CachedMDS['url'];
};

const defaultURLMDS = 'https://mds.fidoalliance.org/'; // v3
Expand All @@ -52,7 +54,8 @@ interface MetadataService {
*
* @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.statements An array of local metadata statements. Statements will be loaded but
* not refreshed
* @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
Expand Down Expand Up @@ -91,11 +94,17 @@ export class BaseMetadataService implements MetadataService {
verificationMode?: VerificationMode;
} = {},
): Promise<void> {
// Reset statement cache
this.statementCache = {};

const { mdsServers = [defaultURLMDS], statements, verificationMode } = opts;

this.setState(SERVICE_STATE.REFRESHING);

// If metadata statements are provided, load them into the cache first
/**
* If metadata statements are provided, load them into the cache first. These statements will
* not be refreshed when a stale one is detected.
*/
if (statements?.length) {
let statementsAdded = 0;

Expand All @@ -108,7 +117,7 @@ export class BaseMetadataService implements MetadataService {
statusReports: [],
timeOfLastStatusChange: '1970-01-01',
},
url: '',
url: NonRefreshingMDS.url,
};

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

// If MDS servers are provided, then process them and add their statements to the cache
/**
* If MDS servers are provided, then download blobs from them, verify them, and then add their
* entries to the cache. Blobs loaded in this way will be refreshed when a stale entry within is
* detected.
*/
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({
const cachedMDS: CachedMDS = {
url,
no: 0,
nextUpdate: new Date(0),
});
};

const blob = await this.downloadBlob(cachedMDS);
await this.verifyBlob(blob, cachedMDS);
} catch (err) {
// Notify of the error and move on
log(`Could not download BLOB from ${url}:`, err);
Expand Down Expand Up @@ -191,7 +207,8 @@ export class BaseMetadataService implements MetadataService {
if (now > mds.nextUpdate) {
try {
this.setState(SERVICE_STATE.REFRESHING);
await this.downloadBlob(mds);
const blob = await this.downloadBlob(mds);
await this.verifyBlob(blob, mds);
} finally {
this.setState(SERVICE_STATE.READY);
}
Expand Down Expand Up @@ -219,51 +236,32 @@ export class BaseMetadataService implements MetadataService {
/**
* Download and process the latest BLOB from MDS
*/
private async downloadBlob(mds: CachedMDS) {
const { url, no } = mds;
private async downloadBlob(cachedMDS: CachedMDS) {
const { url } = cachedMDS;

// 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];
return data;
}

/**
* Verify and process the MDS metadata blob
*/
private async verifyBlob(blob: string, cachedMDS: CachedMDS) {
const { url, no } = cachedMDS;

const { payload, parsedNextUpdate } = await verifyMDSBlob(blob);

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',
{ cause: _error },
`Latest BLOB no. ${payload.no} is not greater than previous no. ${no}`,
);
}

// 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`
Expand All @@ -272,20 +270,29 @@ export class BaseMetadataService implements MetadataService {
}
}

// 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),
),
};
if (url) {
// Remember info about the server so we can refresh later
this.mdsCache[url] = {
...cachedMDS,
// Store the payload `no` to make sure we're getting the next BLOB in the sequence
no: payload.no,
// Remember when we need to refresh this blob
nextUpdate: parsedNextUpdate,
};
} else {
/**
* This blob will not be refreshed, but we should still alert if the blob's `nextUpdate` is
* in the past
*/
if (parsedNextUpdate < new Date()) {
// TODO (Feb 2026): It'd be more actionable for devs if a specific error was raised here,
// then this message was logged higher up when it can include the array index of the stale
// blob.
log(
`⚠️ This MDS blob (serial: ${payload.no}) contains stale data as of ${parsedNextUpdate.toISOString()}. Please consider re-initializing MetadataService with a newer MDS blob.`,
);
}
}
}

/**
Expand Down