1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { RemoteSettings } from "resource://services-settings/remote-settings.sys.mjs";
7 import { X509 } from "resource://gre/modules/psm/X509.sys.mjs";
9 const SECURITY_STATE_BUCKET = "security-state";
10 const SECURITY_STATE_SIGNER = "onecrl.content-signature.mozilla.org";
12 const INTERMEDIATES_DL_PER_POLL_PREF =
13 "security.remote_settings.intermediates.downloads_per_poll";
14 const INTERMEDIATES_DL_PARALLEL_REQUESTS =
15 "security.remote_settings.intermediates.parallel_downloads";
16 const INTERMEDIATES_ENABLED_PREF =
17 "security.remote_settings.intermediates.enabled";
18 const LOGLEVEL_PREF = "browser.policies.loglevel";
20 const CRLITE_FILTERS_ENABLED_PREF =
21 "security.remote_settings.crlite_filters.enabled";
23 const CRLITE_FILTER_CHANNEL_PREF = "security.pki.crlite_channel";
27 ChromeUtils.defineLazyGetter(lazy, "gTextDecoder", () => new TextDecoder());
29 ChromeUtils.defineLazyGetter(lazy, "log", () => {
30 let { ConsoleAPI } = ChromeUtils.importESModule(
31 "resource://gre/modules/Console.sys.mjs"
33 return new ConsoleAPI({
34 prefix: "RemoteSecuritySettings",
35 // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
36 // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
38 maxLogLevelPref: LOGLEVEL_PREF,
42 // Converts a JS string to an array of bytes consisting of the char code at each
43 // index in the string.
44 function stringToBytes(s) {
46 for (let i = 0; i < s.length; i++) {
47 b.push(s.charCodeAt(i));
52 // Converts an array of bytes to a JS string using fromCharCode on each byte.
53 function bytesToString(bytes) {
54 if (bytes.length > 65535) {
55 throw new Error("input too long for bytesToString");
57 return String.fromCharCode.apply(null, bytes);
60 class CRLiteCoverage {
61 constructor(b64LogID, minTimestamp, maxTimestamp) {
62 this.b64LogID = b64LogID;
63 this.minTimestamp = minTimestamp;
64 this.maxTimestamp = maxTimestamp;
67 CRLiteCoverage.prototype.QueryInterface = ChromeUtils.generateQI([
72 constructor(cert, subject) {
74 this.subject = subject;
75 this.trust = Ci.nsICertStorage.TRUST_INHERIT;
78 CertInfo.prototype.QueryInterface = ChromeUtils.generateQI(["nsICertInfo"]);
80 class RevocationState {
86 class IssuerAndSerialRevocationState extends RevocationState {
87 constructor(issuer, serial, state) {
93 IssuerAndSerialRevocationState.prototype.QueryInterface =
94 ChromeUtils.generateQI(["nsIIssuerAndSerialRevocationState"]);
96 class SubjectAndPubKeyRevocationState extends RevocationState {
97 constructor(subject, pubKey, state) {
99 this.subject = subject;
100 this.pubKey = pubKey;
103 SubjectAndPubKeyRevocationState.prototype.QueryInterface =
104 ChromeUtils.generateQI(["nsISubjectAndPubKeyRevocationState"]);
106 function setRevocations(certStorage, revocations) {
107 return new Promise(resolve =>
108 certStorage.setRevocations(revocations, resolve)
113 * Helper function that returns a promise that will resolve with whether or not
114 * the nsICertStorage implementation has prior data of the given type.
116 * @param {Integer} dataType a Ci.nsICertStorage.DATA_TYPE_* constant
117 * indicating the type of data
119 * @returns {Promise} a promise that will resolve with true if the data type is
122 function hasPriorData(dataType) {
123 let certStorage = Cc["@mozilla.org/security/certstorage;1"].getService(
126 return new Promise(resolve => {
127 certStorage.hasPriorData(dataType, (rv, hasPriorData) => {
128 if (rv == Cr.NS_OK) {
129 resolve(hasPriorData);
131 // If calling hasPriorData failed, assume we need to reload everything
132 // (even though it's unlikely doing so will succeed).
140 * Revoke the appropriate certificates based on the records from the blocklist.
142 * @param {object} options
143 * @param {object} options.data Current records in the local db.
144 * @param {Array} options.data.current
145 * @param {Array} options.data.created
146 * @param {Array} options.data.updated
147 * @param {Array} options.data.deleted
149 const updateCertBlocklist = async function ({
150 data: { current, created, updated, deleted },
154 // See if we have prior revocation data (this can happen when we can't open
155 // the database and we have to re-create it (see bug 1546361)).
156 let hasPriorRevocationData = await hasPriorData(
157 Ci.nsICertStorage.DATA_TYPE_REVOCATION
160 // If we don't have prior data, make it so we re-load everything.
161 if (!hasPriorRevocationData) {
167 let toDelete = deleted.concat(updated.map(u => u.old));
168 for (let item of toDelete) {
169 if (item.issuerName && item.serialNumber) {
171 new IssuerAndSerialRevocationState(
174 Ci.nsICertStorage.STATE_UNSET
177 } else if (item.subject && item.pubKeyHash) {
179 new SubjectAndPubKeyRevocationState(
182 Ci.nsICertStorage.STATE_UNSET
188 const toAdd = created.concat(updated.map(u => u.new));
190 for (let item of toAdd) {
191 if (item.issuerName && item.serialNumber) {
193 new IssuerAndSerialRevocationState(
196 Ci.nsICertStorage.STATE_ENFORCE
199 } else if (item.subject && item.pubKeyHash) {
201 new SubjectAndPubKeyRevocationState(
204 Ci.nsICertStorage.STATE_ENFORCE
211 const certList = Cc["@mozilla.org/security/certstorage;1"].getService(
214 await setRevocations(certList, items);
220 export var RemoteSecuritySettings = {
222 OneCRLBlocklistClient: null,
223 IntermediatePreloadsClient: null,
224 CRLiteFiltersClient: null,
227 * Initialize the clients (cheap instantiation) and setup their sync event.
228 * This static method is called from BrowserGlue.sys.mjs soon after startup.
230 * @returns {object} instantiated clients for security remote settings.
233 // Avoid repeated initialization (work-around for bug 1730026).
234 if (this._initialized) {
237 this._initialized = true;
239 this.OneCRLBlocklistClient = RemoteSettings("onecrl", {
240 bucketName: SECURITY_STATE_BUCKET,
241 signerName: SECURITY_STATE_SIGNER,
243 this.OneCRLBlocklistClient.on("sync", updateCertBlocklist);
245 this.IntermediatePreloadsClient = new IntermediatePreloads();
247 this.CRLiteFiltersClient = new CRLiteFilters();
253 class IntermediatePreloads {
255 this.client = RemoteSettings("intermediates", {
256 bucketName: SECURITY_STATE_BUCKET,
257 signerName: SECURITY_STATE_SIGNER,
258 localFields: ["cert_import_complete"],
261 this.client.on("sync", this.onSync.bind(this));
262 Services.obs.addObserver(
263 this.onObservePollEnd.bind(this),
264 "remote-settings:changes-poll-end"
267 lazy.log.debug("Intermediate Preloading: constructor");
270 async updatePreloadedIntermediates() {
271 if (!Services.prefs.getBoolPref(INTERMEDIATES_ENABLED_PREF, true)) {
272 lazy.log.debug("Intermediate Preloading is disabled");
273 Services.obs.notifyObservers(
275 "remote-security-settings:intermediates-updated",
281 // Download attachments that are awaiting download, up to a max.
282 const maxDownloadsPerRun = Services.prefs.getIntPref(
283 INTERMEDIATES_DL_PER_POLL_PREF,
286 const parallelDownloads = Services.prefs.getIntPref(
287 INTERMEDIATES_DL_PARALLEL_REQUESTS,
291 // Bug 1519256: Move this to a separate method that's on a separate timer
292 // with a higher frequency (so we can attempt to download outstanding
293 // certs more than once daily)
295 // See if we have prior cert data (this can happen when we can't open the database and we
296 // have to re-create it (see bug 1546361)).
297 let hasPriorCertData = await hasPriorData(
298 Ci.nsICertStorage.DATA_TYPE_CERTIFICATE
300 // If we don't have prior data, make it so we re-load everything.
301 if (!hasPriorCertData) {
304 current = await this.client.db.list();
307 `Unable to list intermediate preloading collection: ${err}`
311 const toReset = current.filter(record => record.cert_import_complete);
313 await this.client.db.importChanges(
314 undefined, // do not touch metadata.
315 undefined, // do not touch collection timestamp.
316 toReset.map(r => ({ ...r, cert_import_complete: false }))
320 `Unable to update intermediate preloading collection: ${err}`
327 // fetches a bundle containing all attachments, download() is called further down to force a re-sync on hash mismatches for old data or if the bundle fails to download
328 await this.client.attachments.cacheAll();
331 `Error fetching/caching attachment bundle in intermediate preloading: ${err}`
337 current = await this.client.db.list();
340 `Unable to list intermediate preloading collection: ${err}`
344 const waiting = current.filter(record => !record.cert_import_complete);
347 `There are ${waiting.length} intermediates awaiting download.`
349 if (!waiting.length) {
351 Services.obs.notifyObservers(
353 "remote-security-settings:intermediates-updated",
359 let toDownload = waiting.slice(0, maxDownloadsPerRun);
360 let recordsCertsAndSubjects = [];
361 for (let i = 0; i < toDownload.length; i += parallelDownloads) {
362 const chunk = toDownload.slice(i, i + parallelDownloads);
363 const downloaded = await Promise.all(
364 chunk.map(record => this.maybeDownloadAttachment(record))
366 recordsCertsAndSubjects = recordsCertsAndSubjects.concat(downloaded);
370 let recordsToUpdate = [];
371 for (let { record, cert, subject } of recordsCertsAndSubjects) {
372 if (cert && subject) {
373 certInfos.push(new CertInfo(cert, subject));
374 recordsToUpdate.push(record);
377 const certStorage = Cc["@mozilla.org/security/certstorage;1"].getService(
380 let result = await new Promise(resolve => {
381 certStorage.addCerts(certInfos, resolve);
382 }).catch(err => err);
383 if (result != Cr.NS_OK) {
384 lazy.log.error(`certStorage.addCerts failed: ${result}`);
388 await this.client.db.importChanges(
389 undefined, // do not touch metadata.
390 undefined, // do not touch collection timestamp.
391 recordsToUpdate.map(r => ({ ...r, cert_import_complete: true }))
395 `Unable to update intermediate preloading collection: ${err}`
400 Services.obs.notifyObservers(
402 "remote-security-settings:intermediates-updated",
407 async onObservePollEnd(subject, topic) {
408 lazy.log.debug(`onObservePollEnd ${subject} ${topic}`);
411 await this.updatePreloadedIntermediates();
413 lazy.log.warn(`Unable to update intermediate preloads: ${err}`);
417 // This method returns a promise to RemoteSettingsClient.maybeSync method.
418 async onSync({ data: { deleted } }) {
419 if (!Services.prefs.getBoolPref(INTERMEDIATES_ENABLED_PREF, true)) {
420 lazy.log.debug("Intermediate Preloading is disabled");
424 lazy.log.debug(`Removing ${deleted.length} Intermediate certificates`);
425 await this.removeCerts(deleted);
429 * Attempts to download the attachment, assuming it's not been processed
430 * already. Does not retry, and always resolves (e.g., does not reject upon
431 * failure.) Errors are reported via console.error.
433 * @param {AttachmentRecord} record defines which data to obtain
434 * @returns {Promise} a Promise that will resolve to an object with the properties
435 * record, cert, and subject. record is the original record.
436 * cert is the base64-encoded bytes of the downloaded certificate (if
437 * downloading was successful), and null otherwise.
438 * subject is the base64-encoded bytes of the subject distinguished
441 async maybeDownloadAttachment(record) {
442 let result = { record, cert: null, subject: null };
444 let dataAsString = null;
446 let { buffer } = await this.client.attachments.download(record, {
450 dataAsString = lazy.gTextDecoder.decode(new Uint8Array(buffer));
452 if (err.name == "BadContentError") {
453 lazy.log.debug(`Bad attachment content.`);
455 lazy.log.error(`Failed to download attachment: ${err}`);
463 // split off the header and footer
464 certBase64 = dataAsString.split("-----")[2].replace(/\s/g, "");
465 // get an array of bytes so we can use X509.sys.mjs
466 let certBytes = stringToBytes(atob(certBase64));
467 let cert = new X509.Certificate();
468 cert.parse(certBytes);
469 // get the DER-encoded subject and get a base64-encoded string from it
470 // TODO(bug 1542028): add getters for _der and _bytes
471 subjectBase64 = btoa(
472 bytesToString(cert.tbsCertificate.subject._der._bytes)
475 lazy.log.error(`Failed to decode cert: ${err}`);
478 result.cert = certBase64;
479 result.subject = subjectBase64;
483 async maybeSync(expectedTimestamp, options) {
484 return this.client.maybeSync(expectedTimestamp, options);
487 async removeCerts(recordsToRemove) {
488 let certStorage = Cc["@mozilla.org/security/certstorage;1"].getService(
491 let hashes = recordsToRemove.map(record => record.derHash);
492 let result = await new Promise(resolve => {
493 certStorage.removeCertsByHashes(hashes, resolve);
494 }).catch(err => err);
495 if (result != Cr.NS_OK) {
496 lazy.log.error(`Failed to remove some intermediate certificates`);
501 // Helper function to compare filters. One filter is "less than" another filter (i.e. it sorts
502 // earlier) if its timestamp is farther in the past than the other.
503 function compareFilters(filterA, filterB) {
504 return filterA.effectiveTimestamp - filterB.effectiveTimestamp;
507 class CRLiteFilters {
509 this.client = RemoteSettings("cert-revocations", {
510 bucketName: SECURITY_STATE_BUCKET,
511 signerName: SECURITY_STATE_SIGNER,
512 localFields: ["loaded_into_cert_storage"],
515 Services.obs.addObserver(
516 this.onObservePollEnd.bind(this),
517 "remote-settings:changes-poll-end"
519 Services.prefs.addObserver(CRLITE_FILTER_CHANNEL_PREF, this);
522 async observe(subject, topic, prefName) {
523 if (topic == "nsPref:changed" && prefName == CRLITE_FILTER_CHANNEL_PREF) {
524 // When the user changes from channel A to channel B, mark the records
525 // for channel A (and all other channels) with loaded_into_cert_storage =
526 // false. If we don't do this, then the user will fail to reinstall the
527 // channel A artifacts if they switch back to channel A.
528 let records = await this.client.db.list();
529 let newChannel = Services.prefs.getStringPref(
530 CRLITE_FILTER_CHANNEL_PREF,
533 let toReset = records.filter(record => record.channel != newChannel);
534 await this.client.db.importChanges(
535 undefined, // do not touch metadata.
536 undefined, // do not touch collection timestamp.
537 toReset.map(r => ({ ...r, loaded_into_cert_storage: false }))
542 async getFilteredRecords() {
543 let records = await this.client.db.list();
544 records = await this.client._filterEntries(records);
548 async onObservePollEnd() {
549 if (!Services.prefs.getBoolPref(CRLITE_FILTERS_ENABLED_PREF, true)) {
550 lazy.log.debug("CRLite filter downloading is disabled");
551 Services.obs.notifyObservers(
553 "remote-security-settings:crlite-filters-downloaded",
559 let hasPriorFilter = await hasPriorData(
560 Ci.nsICertStorage.DATA_TYPE_CRLITE_FILTER_FULL
562 if (!hasPriorFilter) {
563 let current = await this.getFilteredRecords();
564 let toReset = current.filter(
565 record => !record.incremental && record.loaded_into_cert_storage
567 await this.client.db.importChanges(
568 undefined, // do not touch metadata.
569 undefined, // do not touch collection timestamp.
570 toReset.map(r => ({ ...r, loaded_into_cert_storage: false }))
573 let hasPriorStash = await hasPriorData(
574 Ci.nsICertStorage.DATA_TYPE_CRLITE_FILTER_INCREMENTAL
576 if (!hasPriorStash) {
577 let current = await this.getFilteredRecords();
578 let toReset = current.filter(
579 record => record.incremental && record.loaded_into_cert_storage
581 await this.client.db.importChanges(
582 undefined, // do not touch metadata.
583 undefined, // do not touch collection timestamp.
584 toReset.map(r => ({ ...r, loaded_into_cert_storage: false }))
588 let current = await this.getFilteredRecords();
589 let fullFilters = current.filter(filter => !filter.incremental);
590 if (fullFilters.length < 1) {
591 lazy.log.debug("no full CRLite filters to download?");
592 Services.obs.notifyObservers(
594 "remote-security-settings:crlite-filters-downloaded",
599 fullFilters.sort(compareFilters);
600 lazy.log.debug("fullFilters:", fullFilters);
601 let fullFilter = fullFilters.pop(); // the most recent filter sorts last
602 let incrementalFilters = current.filter(
604 // Return incremental filters that are more recent than (i.e. sort later than) the full
606 filter.incremental && compareFilters(filter, fullFilter) > 0
608 incrementalFilters.sort(compareFilters);
609 // Map of id to filter where that filter's parent has the given id.
610 let parentIdMap = {};
611 for (let filter of incrementalFilters) {
612 if (filter.parent in parentIdMap) {
613 lazy.log.debug(`filter with parent id ${filter.parent} already seen?`);
615 parentIdMap[filter.parent] = filter;
618 let filtersToDownload = [];
619 let nextFilter = fullFilter;
621 filtersToDownload.push(nextFilter);
622 nextFilter = parentIdMap[nextFilter.id];
624 const certList = Cc["@mozilla.org/security/certstorage;1"].getService(
627 filtersToDownload = filtersToDownload.filter(
628 filter => !filter.loaded_into_cert_storage
630 lazy.log.debug("filtersToDownload:", filtersToDownload);
631 let filtersDownloaded = [];
632 for (let filter of filtersToDownload) {
634 let attachment = await this.client.attachments.downloadAsBytes(filter);
635 let bytes = new Uint8Array(attachment);
637 `Downloaded ${filter.details.name}: ${bytes.length} bytes`
639 filter.bytes = bytes;
640 filtersDownloaded.push(filter);
642 lazy.log.error("failed to download CRLite filter", e);
645 let fullFiltersDownloaded = filtersDownloaded.filter(
646 filter => !filter.incremental
648 if (fullFiltersDownloaded.length) {
649 if (fullFiltersDownloaded.length > 1) {
650 lazy.log.warn("trying to install more than one full CRLite filter?");
652 let filter = fullFiltersDownloaded[0];
655 if (filter.coverage) {
656 for (let entry of filter.coverage) {
666 let enrollment = filter.enrolledIssuers ? filter.enrolledIssuers : [];
668 await new Promise(resolve => {
669 certList.setFullCRLiteFilter(filter.bytes, enrollment, coverage, rv => {
670 lazy.log.debug(`setFullCRLiteFilter: ${rv}`);
675 let stashes = filtersDownloaded.filter(
677 filter.incremental && filter.attachment.filename.endsWith("stash")
679 let totalLength = stashes.reduce(
680 (sum, filter) => sum + filter.bytes.length,
683 let concatenatedStashes = new Uint8Array(totalLength);
685 for (let filter of stashes) {
686 concatenatedStashes.set(filter.bytes, offset);
687 offset += filter.bytes.length;
689 if (concatenatedStashes.length) {
691 `adding concatenated incremental updates of total length ${concatenatedStashes.length}`
693 await new Promise(resolve => {
694 certList.addCRLiteStash(concatenatedStashes, rv => {
695 lazy.log.debug(`addCRLiteStash: ${rv}`);
700 let deltas = filtersDownloaded.filter(
702 filter.incremental && filter.attachment.filename.endsWith("delta")
704 for (let filter of deltas) {
705 lazy.log.debug(`adding delta update of size ${filter.bytes.length}`);
706 await new Promise(resolve => {
707 certList.addCRLiteDelta(
709 filter.attachment.filename,
711 lazy.log.debug(`addCRLiteDelta: ${rv}`);
718 for (let filter of filtersDownloaded) {
722 await this.client.db.importChanges(
723 undefined, // do not touch metadata.
724 undefined, // do not touch collection timestamp.
725 filtersDownloaded.map(r => ({ ...r, loaded_into_cert_storage: true }))
728 Services.obs.notifyObservers(
730 "remote-security-settings:crlite-filters-downloaded",
731 `finished;${filtersDownloaded
732 .map(filter => filter.details.name)