/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { RemoteSettings } from "resource://services-settings/remote-settings.sys.mjs"; import { X509 } from "resource://gre/modules/psm/X509.sys.mjs"; const SECURITY_STATE_BUCKET = "security-state"; const SECURITY_STATE_SIGNER = "onecrl.content-signature.mozilla.org"; const INTERMEDIATES_DL_PER_POLL_PREF = "security.remote_settings.intermediates.downloads_per_poll"; const INTERMEDIATES_DL_PARALLEL_REQUESTS = "security.remote_settings.intermediates.parallel_downloads"; const INTERMEDIATES_ENABLED_PREF = "security.remote_settings.intermediates.enabled"; const LOGLEVEL_PREF = "browser.policies.loglevel"; const CRLITE_FILTERS_ENABLED_PREF = "security.remote_settings.crlite_filters.enabled"; const CRLITE_FILTER_CHANNEL_PREF = "security.pki.crlite_channel"; const lazy = {}; ChromeUtils.defineLazyGetter(lazy, "securityStateDir", () => PathUtils.join(PathUtils.profileDir, "security_state") ); ChromeUtils.defineLazyGetter(lazy, "gTextDecoder", () => new TextDecoder()); ChromeUtils.defineLazyGetter(lazy, "log", () => { let { ConsoleAPI } = ChromeUtils.importESModule( "resource://gre/modules/Console.sys.mjs" ); return new ConsoleAPI({ prefix: "RemoteSecuritySettings", // tip: set maxLogLevel to "debug" and use log.debug() to create detailed // messages during development. See LOG_LEVELS in Console.sys.mjs for details. maxLogLevel: "error", maxLogLevelPref: LOGLEVEL_PREF, }); }); ChromeUtils.defineESModuleGetters(lazy, { RemoteSettingsClient: "resource://services-settings/RemoteSettingsClient.sys.mjs", }); // Converts a JS string to an array of bytes consisting of the char code at each // index in the string. function stringToBytes(s) { let b = []; for (let i = 0; i < s.length; i++) { b.push(s.charCodeAt(i)); } return b; } // Converts an array of bytes to a JS string using fromCharCode on each byte. function bytesToString(bytes) { if (bytes.length > 65535) { throw new Error("input too long for bytesToString"); } return String.fromCharCode.apply(null, bytes); } class CertInfo { constructor(cert, subject) { this.cert = cert; this.subject = subject; this.trust = Ci.nsICertStorage.TRUST_INHERIT; } } CertInfo.prototype.QueryInterface = ChromeUtils.generateQI(["nsICertInfo"]); class RevocationState { constructor(state) { this.state = state; } } class IssuerAndSerialRevocationState extends RevocationState { constructor(issuer, serial, state) { super(state); this.issuer = issuer; this.serial = serial; } } IssuerAndSerialRevocationState.prototype.QueryInterface = ChromeUtils.generateQI(["nsIIssuerAndSerialRevocationState"]); class SubjectAndPubKeyRevocationState extends RevocationState { constructor(subject, pubKey, state) { super(state); this.subject = subject; this.pubKey = pubKey; } } SubjectAndPubKeyRevocationState.prototype.QueryInterface = ChromeUtils.generateQI(["nsISubjectAndPubKeyRevocationState"]); function setRevocations(certStorage, revocations) { return new Promise(resolve => certStorage.setRevocations(revocations, resolve) ); } /** * Helper function that returns a promise that will resolve with whether or not * the nsICertStorage implementation has prior data of the given type. * * @param {Integer} dataType a Ci.nsICertStorage.DATA_TYPE_* constant * indicating the type of data * @returns {Promise} a promise that will resolve with true if the data type is * present */ function hasPriorData(dataType) { let certStorage = Cc["@mozilla.org/security/certstorage;1"].getService( Ci.nsICertStorage ); return new Promise(resolve => { certStorage.hasPriorData(dataType, (rv, out) => { if (rv == Cr.NS_OK) { resolve(out); } else { // If calling hasPriorData failed, assume we need to reload everything // (even though it's unlikely doing so will succeed). resolve(false); } }); }); } /** * Helper function that returns a promise that will resolve with the set of * SHA256 hashes of CRLite filters that are currently loaded in cert_storage. * * @param {object} certStorage a Ci.nsICertStorage instance * @returns {Promise} a promise that will resolve with the set of hashes. */ function getLoadedCRLiteFilterHashes(certStorage) { return new Promise(resolve => { certStorage.getCRLiteFilterHashes((rv, result) => { if (rv != Cr.NS_OK || !result) { resolve(new Set()); return; } let str = result.toString(); resolve(new Set(str ? str.split(",") : [])); }); }); } /** * Revoke the appropriate certificates based on the records from the blocklist. * * @param {object} options * @param {object} options.data Current records in the local db. * @param {Array} options.data.current * @param {Array} options.data.created * @param {Array} options.data.updated * @param {Array} options.data.deleted */ const updateCertBlocklist = async function ({ data: { current, created, updated, deleted }, }) { let items = []; // See if we have prior revocation data (this can happen when we can't open // the database and we have to re-create it (see bug 1546361)). let hasPriorRevocationData = await hasPriorData( Ci.nsICertStorage.DATA_TYPE_REVOCATION ); // If we don't have prior data, make it so we re-load everything. if (!hasPriorRevocationData) { deleted = []; updated = []; created = current; } let toDelete = deleted.concat(updated.map(u => u.old)); for (let item of toDelete) { if (item.issuerName && item.serialNumber) { items.push( new IssuerAndSerialRevocationState( item.issuerName, item.serialNumber, Ci.nsICertStorage.STATE_UNSET ) ); } else if (item.subject && item.pubKeyHash) { items.push( new SubjectAndPubKeyRevocationState( item.subject, item.pubKeyHash, Ci.nsICertStorage.STATE_UNSET ) ); } } const toAdd = created.concat(updated.map(u => u.new)); for (let item of toAdd) { if (item.issuerName && item.serialNumber) { items.push( new IssuerAndSerialRevocationState( item.issuerName, item.serialNumber, Ci.nsICertStorage.STATE_ENFORCE ) ); } else if (item.subject && item.pubKeyHash) { items.push( new SubjectAndPubKeyRevocationState( item.subject, item.pubKeyHash, Ci.nsICertStorage.STATE_ENFORCE ) ); } } try { const certList = Cc["@mozilla.org/security/certstorage;1"].getService( Ci.nsICertStorage ); await setRevocations(certList, items); } catch (e) { lazy.log.error(e); } }; export var RemoteSecuritySettings = { _initialized: false, OneCRLBlocklistClient: null, IntermediatePreloadsClient: null, CRLiteFiltersClient: null, /** * Initialize the clients (cheap instantiation) and setup their sync event. * This static method is called from BrowserGlue.sys.mjs soon after startup. * * @returns {object} instantiated clients for security remote settings. */ init() { // Avoid repeated initialization (work-around for bug 1730026). if (this._initialized) { return this; } this._initialized = true; this.OneCRLBlocklistClient = RemoteSettings("onecrl", { bucketName: SECURITY_STATE_BUCKET, signerName: SECURITY_STATE_SIGNER, }); this.OneCRLBlocklistClient.on("sync", updateCertBlocklist); this.IntermediatePreloadsClient = new IntermediatePreloads(); this.CRLiteFiltersClient = new CRLiteFilters(); return this; }, }; class IntermediatePreloads { constructor() { this.client = RemoteSettings("intermediates", { bucketName: SECURITY_STATE_BUCKET, signerName: SECURITY_STATE_SIGNER, localFields: ["cert_import_complete"], }); this.client.on("sync", this.onSync.bind(this)); Services.obs.addObserver( this.onObservePollEnd.bind(this), "remote-settings:changes-poll-end" ); lazy.log.debug("Intermediate Preloading: constructor"); } async updatePreloadedIntermediates() { if (!Services.prefs.getBoolPref(INTERMEDIATES_ENABLED_PREF, true)) { lazy.log.debug("Intermediate Preloading is disabled"); Services.obs.notifyObservers( null, "remote-security-settings:intermediates-updated", "disabled" ); return; } // Download attachments that are awaiting download, up to a max. const maxDownloadsPerRun = Services.prefs.getIntPref( INTERMEDIATES_DL_PER_POLL_PREF, 100 ); const parallelDownloads = Services.prefs.getIntPref( INTERMEDIATES_DL_PARALLEL_REQUESTS, 8 ); // Bug 1519256: Move this to a separate method that's on a separate timer // with a higher frequency (so we can attempt to download outstanding // certs more than once daily) // See if we have prior cert data (this can happen when we can't open the database and we // have to re-create it (see bug 1546361)). let hasPriorCertData = await hasPriorData( Ci.nsICertStorage.DATA_TYPE_CERTIFICATE ); // If we don't have prior data, make it so we re-load everything. if (!hasPriorCertData) { let current = []; try { current = await this.client.db.list(); } catch (err) { if (!(err instanceof lazy.RemoteSettingsClient.EmptyDatabaseError)) { lazy.log.warn( `Unable to list intermediate preloading collection: ${err}` ); return; } } const toReset = current.filter(record => record.cert_import_complete); try { await this.client.db.importChanges( undefined, // do not touch metadata. undefined, // do not touch collection timestamp. toReset.map(r => ({ ...r, cert_import_complete: false })) ); } catch (err) { lazy.log.warn( `Unable to update intermediate preloading collection: ${err}` ); return; } } let current = []; try { current = await this.client.db.list(); } catch (err) { if (!(err instanceof lazy.RemoteSettingsClient.EmptyDatabaseError)) { lazy.log.warn( `Unable to list intermediate preloading collection: ${err}` ); return; } } // fetch attachment bundle if we are initializing cert data or remote-settings records if (!hasPriorCertData || !current.length) { try { // download() is called further down to force a re-sync on hash mismatches for old data or if the bundle fails to download await this.client.attachments.cacheAll(); } catch (err) { lazy.log.warn( `Error fetching/caching attachment bundle in intermediate preloading: ${err}` ); } } const waiting = current.filter(record => !record.cert_import_complete); lazy.log.debug( `There are ${waiting.length} intermediates awaiting download.` ); if (!waiting.length) { // Nothing to do. Services.obs.notifyObservers( null, "remote-security-settings:intermediates-updated", "success" ); return; } let toDownload = waiting.slice(0, maxDownloadsPerRun); let recordsCertsAndSubjects = []; for (let i = 0; i < toDownload.length; i += parallelDownloads) { const chunk = toDownload.slice(i, i + parallelDownloads); const downloaded = await Promise.all( chunk.map(record => this.maybeDownloadAttachment(record)) ); recordsCertsAndSubjects = recordsCertsAndSubjects.concat(downloaded); } let certInfos = []; let recordsToUpdate = []; for (let { record, cert, subject } of recordsCertsAndSubjects) { if (cert && subject) { certInfos.push(new CertInfo(cert, subject)); recordsToUpdate.push(record); } } const certStorage = Cc["@mozilla.org/security/certstorage;1"].getService( Ci.nsICertStorage ); let result = await new Promise(resolve => { certStorage.addCerts(certInfos, resolve); }).catch(err => err); if (result != Cr.NS_OK) { lazy.log.error(`certStorage.addCerts failed: ${result}`); return; } try { await this.client.db.importChanges( undefined, // do not touch metadata. undefined, // do not touch collection timestamp. recordsToUpdate.map(r => ({ ...r, cert_import_complete: true })) ); } catch (err) { lazy.log.warn( `Unable to update intermediate preloading collection: ${err}` ); return; } // attachment cache is no longer needed await this.client.attachments.deleteAll(); Services.obs.notifyObservers( null, "remote-security-settings:intermediates-updated", "success" ); } async onObservePollEnd(subject, topic) { lazy.log.debug(`onObservePollEnd ${subject} ${topic}`); try { await this.updatePreloadedIntermediates(); } catch (err) { lazy.log.warn(`Unable to update intermediate preloads: ${err}`); } } // This method returns a promise to RemoteSettingsClient.maybeSync method. async onSync({ data: { deleted } }) { if (!Services.prefs.getBoolPref(INTERMEDIATES_ENABLED_PREF, true)) { lazy.log.debug("Intermediate Preloading is disabled"); return; } lazy.log.debug(`Removing ${deleted.length} Intermediate certificates`); await this.removeCerts(deleted); } /** * Attempts to download the attachment, assuming it's not been processed * already. Does not retry, and always resolves (e.g., does not reject upon * failure.) Errors are reported via console.error. * * @param {AttachmentRecord} record defines which data to obtain * @returns {Promise} a Promise that will resolve to an object with the properties * record, cert, and subject. record is the original record. * cert is the base64-encoded bytes of the downloaded certificate (if * downloading was successful), and null otherwise. * subject is the base64-encoded bytes of the subject distinguished * name of the same. */ async maybeDownloadAttachment(record) { let result = { record, cert: null, subject: null }; let dataAsString = null; try { let { buffer } = await this.client.attachments.download(record, { retries: 0, checkHash: true, cacheResult: false, }); dataAsString = lazy.gTextDecoder.decode(new Uint8Array(buffer)); } catch (err) { if (err.name == "BadContentError") { lazy.log.debug(`Bad attachment content.`); } else { lazy.log.error(`Failed to download attachment: ${err}`); } return result; } let certBase64; let subjectBase64; try { // split off the header and footer certBase64 = dataAsString.split("-----")[2].replace(/\s/g, ""); // get an array of bytes so we can use X509.sys.mjs let certBytes = stringToBytes(atob(certBase64)); let cert = new X509.Certificate(); cert.parse(certBytes); // get the DER-encoded subject and get a base64-encoded string from it // TODO(bug 1542028): add getters for _der and _bytes subjectBase64 = btoa( bytesToString(cert.tbsCertificate.subject._der._bytes) ); } catch (err) { lazy.log.error(`Failed to decode cert: ${err}`); return result; } result.cert = certBase64; result.subject = subjectBase64; return result; } async maybeSync(expectedTimestamp, options) { return this.client.maybeSync(expectedTimestamp, options); } async removeCerts(recordsToRemove) { let certStorage = Cc["@mozilla.org/security/certstorage;1"].getService( Ci.nsICertStorage ); let hashes = recordsToRemove.map(record => record.derHash); let result = await new Promise(resolve => { certStorage.removeCertsByHashes(hashes, resolve); }).catch(err => err); if (result != Cr.NS_OK) { lazy.log.error(`Failed to remove some intermediate certificates`); } } } /** * Check whether a CRLite filter is on disk in the security_state directory with the correct hash. * * @param {object} filter A CRLite filter record from Remote Settings. * @returns {boolean} True if the file is on disk with the correct size and SHA-256 hash. */ async function isFilterOnDisk(filter) { let filename = filter.incremental ? filter.attachment.filename : "crlite.filter"; let filePath = PathUtils.join(lazy.securityStateDir, filename); let bytes; try { bytes = await IOUtils.read(filePath); } catch (_) { return false; } let { size, hash } = filter.attachment; if (bytes.length !== size) { return false; } const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); return new Uint8Array(hashBuffer).toHex() === hash; } class CRLiteFilters { constructor() { this.client = RemoteSettings("cert-revocations", { bucketName: SECURITY_STATE_BUCKET, signerName: SECURITY_STATE_SIGNER, }); Services.obs.addObserver( this.onObservePollEnd.bind(this), "remote-settings:changes-poll-end" ); Services.prefs.addObserver(CRLITE_FILTER_CHANNEL_PREF, this); } observe(subject, topic, prefName) { if (topic == "nsPref:changed" && prefName == CRLITE_FILTER_CHANNEL_PREF) { this.onObservePollEnd(); } } async readOrDownloadFilter(filter, tryDisk) { if (tryDisk) { let filename = filter.incremental ? filter.attachment.filename : "crlite.filter"; try { return await IOUtils.read( PathUtils.join(lazy.securityStateDir, filename) ); } catch (e) { lazy.log.error("failed to read CRLite filter from disk", e); } } try { return new Uint8Array( await this.client.attachments.downloadAsBytes(filter) ); } catch (e) { lazy.log.error("failed to download CRLite filter", e); return null; } } async getFilteredRecords() { let records = []; try { records = await this.client.db.list(); } catch (err) { if (!(err instanceof lazy.RemoteSettingsClient.EmptyDatabaseError)) { throw err; } } records = await this.client._filterEntries(records); return records; } async onObservePollEnd() { if (!Services.prefs.getBoolPref(CRLITE_FILTERS_ENABLED_PREF, true)) { lazy.log.debug("CRLite filter downloading is disabled"); Services.obs.notifyObservers( null, "remote-security-settings:crlite-filters-updated", "disabled" ); return; } let records = await this.getFilteredRecords(); let fullFilters = records.filter(filter => !filter.incremental); if (fullFilters.length != 1) { lazy.log.debug( `${fullFilters.length} full CRLite filters available to download, expected 1` ); Services.obs.notifyObservers( null, "remote-security-settings:crlite-filters-updated", "unavailable" ); return; } let fullFilter = fullFilters[0]; let incrementalFilters = records.filter(filter => filter.incremental); const certList = Cc["@mozilla.org/security/certstorage;1"].getService( Ci.nsICertStorage ); let loadedFilterHashes = await getLoadedCRLiteFilterHashes(certList); lazy.log.debug("filtersInChannel:", records); let filtersToInstall = []; let filtersDownloaded = []; let fullFilterInstalling = false; let expectedDeltaHashes = new Set( incrementalFilters.map(f => f.attachment.hash) ); // Check for stale deltas: hashes loaded in cert_storage that are not in // the expected set (e.g. deltas from a previous channel). setFullCRLiteFilter // clears all loaded deltas, so we can re-load the full filter to remove them. let hasStaleLoadedDeltas = [...loadedFilterHashes] .filter(h => h !== fullFilter.attachment.hash) .some(h => !expectedDeltaHashes.has(h)); let fullFilterOnDisk = await isFilterOnDisk(fullFilter); if ( !fullFilterOnDisk || !loadedFilterHashes.has(fullFilter.attachment.hash) || hasStaleLoadedDeltas ) { // setFullCRLiteFilter clears all loaded deltas from cert_storage and // removes delta files from the security_state directory. In the event of, // say, a corrupted full filter on disk, we would like to avoid // re-downloading any delta files that we have good copies of on disk. // The fullFilterInstalling flag causes us to read those deltas from disk // prior to calling setFullCRLiteFilter so that we can re-load them // without fetching them over the network. fullFilterInstalling = true; let bytes = await this.readOrDownloadFilter(fullFilter, fullFilterOnDisk); if (!bytes) { Services.obs.notifyObservers( null, "remote-security-settings:crlite-filters-updated", "unavailable" ); return; } lazy.log.debug(`${fullFilter.details.name}: ${bytes.length} bytes`); fullFilter.bytes = bytes; if (!fullFilterOnDisk) { filtersDownloaded.push(fullFilter.details.name); } filtersToInstall.push(fullFilter); } for (let filter of incrementalFilters) { let deltaOnDisk = await isFilterOnDisk(filter); if ( !fullFilterInstalling && deltaOnDisk && loadedFilterHashes.has(filter.attachment.hash) ) { continue; } let bytes = await this.readOrDownloadFilter(filter, deltaOnDisk); if (!bytes) { lazy.log.error( `failed to obtain bytes for CRLite delta ${filter.attachment.filename}` ); continue; } lazy.log.debug(`${filter.details.name}: ${bytes.length} bytes`); filter.bytes = bytes; if (!deltaOnDisk) { filtersDownloaded.push(filter.details.name); } filtersToInstall.push(filter); } let fullFilterToInstall = filtersToInstall.find(f => !f.incremental); if (fullFilterToInstall) { await new Promise(resolve => { certList.setFullCRLiteFilter(fullFilterToInstall.bytes, rv => { lazy.log.debug(`setFullCRLiteFilter: ${rv}`); resolve(); }); }); } for (let filter of filtersToInstall.filter(f => f.incremental)) { lazy.log.debug(`adding delta update of size ${filter.bytes.length}`); await new Promise(resolve => { certList.addCRLiteDelta( filter.bytes, filter.attachment.filename, rv => { lazy.log.debug(`addCRLiteDelta: ${rv}`); resolve(); } ); }); } for (let filter of filtersToInstall) { delete filter.bytes; } Services.obs.notifyObservers( null, "remote-security-settings:crlite-filters-updated", `finished;${filtersToInstall.map(f => f.details.name).join(",")};${filtersDownloaded.join(",")}` ); } }