UNPKG

@simplewebauthn/server

Version:
260 lines (259 loc) 10.7 kB
import { validateCertificatePath } from '../helpers/validateCertificatePath.js'; import { convertCertBufferToPEM } from '../helpers/convertCertBufferToPEM.js'; import { convertAAGUIDToString } from '../helpers/convertAAGUIDToString.js'; import { SettingsService } from './settingsService.js'; import { getLogger } from '../helpers/logging.js'; import { convertPEMToBytes } from '../helpers/convertPEMToBytes.js'; import { fetch } from '../helpers/fetch.js'; import { parseJWT } from '../metadata/parseJWT.js'; import { verifyJWT } from '../metadata/verifyJWT.js'; const defaultURLMDS = 'https://mds.fidoalliance.org/'; // v3 var SERVICE_STATE; (function (SERVICE_STATE) { SERVICE_STATE[SERVICE_STATE["DISABLED"] = 0] = "DISABLED"; SERVICE_STATE[SERVICE_STATE["REFRESHING"] = 1] = "REFRESHING"; SERVICE_STATE[SERVICE_STATE["READY"] = 2] = "READY"; })(SERVICE_STATE || (SERVICE_STATE = {})); const log = getLogger('MetadataService'); /** * An implementation of `MetadataService` that can download and parse BLOBs, and support on-demand * requesting and caching of individual metadata statements. * * https://fidoalliance.org/metadata/ */ export class BaseMetadataService { constructor() { Object.defineProperty(this, "mdsCache", { enumerable: true, configurable: true, writable: true, value: {} }); Object.defineProperty(this, "statementCache", { enumerable: true, configurable: true, writable: true, value: {} }); Object.defineProperty(this, "state", { enumerable: true, configurable: true, writable: true, value: SERVICE_STATE.DISABLED }); Object.defineProperty(this, "verificationMode", { enumerable: true, configurable: true, writable: true, value: 'strict' }); } async initialize(opts = {}) { 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); } async getStatement(aaguid) { 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 */ async downloadBlob(mds) { 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(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; // 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 */ pauseUntilReady() { if (this.state === SERVICE_STATE.READY) { return new Promise((resolve) => { resolve(); }); } // State isn't ready, so set up polling const readyPromise = new Promise((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 */ setState(newState) { 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'); } } } /** * 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 const MetadataService = new BaseMetadataService();