Bug 657228: Preload all known intermediate certificates for CAs in our root store r=keeler
☠☠ backed out by 8ffff925bbb6 ☠ ☠
authorJ.C. Jones <jjones@mozilla.com>
Tue, 15 Jan 2019 18:32:47 +0000
changeset 513952 cac9133e55727437d1c10e17bc8cc1423c5992eb
parent 513951 b8346afda87b49f05fabb39259945666a8ae9260
child 513953 887c1386b62c538508f90bc8342f0c6032aa6c82
push id1953
push userffxbld-merge
push dateMon, 11 Mar 2019 12:10:20 +0000
treeherdermozilla-release@9c35dcbaa899 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskeeler
bugs657228
milestone66.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 657228: Preload all known intermediate certificates for CAs in our root store r=keeler Differential Revision: https://phabricator.services.mozilla.com/D12115
security/manager/ssl/RemoteSecuritySettings.jsm
security/manager/ssl/moz.build
security/manager/ssl/security-prefs.js
security/manager/ssl/tests/unit/test_intermediate_preloads.js
security/manager/ssl/tests/unit/test_intermediate_preloads/ca.pem
security/manager/ssl/tests/unit/test_intermediate_preloads/ca.pem.certspec
security/manager/ssl/tests/unit/test_intermediate_preloads/ee.pem
security/manager/ssl/tests/unit/test_intermediate_preloads/ee.pem.certspec
security/manager/ssl/tests/unit/test_intermediate_preloads/ee2.pem
security/manager/ssl/tests/unit/test_intermediate_preloads/ee2.pem.certspec
security/manager/ssl/tests/unit/test_intermediate_preloads/int.pem
security/manager/ssl/tests/unit/test_intermediate_preloads/int.pem.certspec
security/manager/ssl/tests/unit/test_intermediate_preloads/int2.pem
security/manager/ssl/tests/unit/test_intermediate_preloads/int2.pem.certspec
security/manager/ssl/tests/unit/test_intermediate_preloads/moz.build
security/manager/ssl/tests/unit/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/RemoteSecuritySettings.jsm
@@ -0,0 +1,189 @@
+/* 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/. */
+"use strict";
+
+const {RemoteSettings} = ChromeUtils.import("resource://services-settings/remote-settings.js", {});
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
+
+XPCOMUtils.defineLazyGetter(this, "gTextDecoder", () => new TextDecoder());
+
+XPCOMUtils.defineLazyGetter(this, "baseAttachmentsURL", async () => {
+  const server = Services.prefs.getCharPref("services.settings.server");
+  const serverInfo = await (await fetch(`${server}/`)).json();
+  const {capabilities: {attachments: {base_url}}} = serverInfo;
+  return base_url;
+});
+
+const INTERMEDIATES_ENABLED_PREF          = "security.remote_settings.intermediates.enabled";
+const INTERMEDIATES_COLLECTION_PREF      = "security.remote_settings.intermediates.collection";
+const INTERMEDIATES_BUCKET_PREF          = "security.remote_settings.intermediates.bucket";
+const INTERMEDIATES_SIGNER_PREF          = "security.remote_settings.intermediates.signer";
+const INTERMEDIATES_CHECKED_SECONDS_PREF = "security.remote_settings.intermediates.checked";
+
+let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(Ci.nsIX509CertDB);
+
+function getHash(aStr) {
+  // return the two-digit hexadecimal code for a byte
+  let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+  hasher.init(Ci.nsICryptoHash.SHA256);
+  let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream);
+  stringStream.data = aStr;
+  hasher.updateFromStream(stringStream, -1);
+
+  // convert the binary hash data to a hex string.
+  return hasher.finish(true);
+}
+
+// Remove all colons from a string
+function stripColons(hexString) {
+  return hexString.replace(/:/g, "");
+}
+
+this.RemoteSecuritySettings = class RemoteSecuritySettings {
+    constructor() {
+        this.onSync = this.onSync.bind(this);
+        this.client = RemoteSettings(Services.prefs.getCharPref(INTERMEDIATES_COLLECTION_PREF),
+            {
+                bucketNamePref: INTERMEDIATES_BUCKET_PREF,
+                lastCheckTimePref: INTERMEDIATES_CHECKED_SECONDS_PREF,
+                signerName: Services.prefs.getCharPref(INTERMEDIATES_SIGNER_PREF),
+                localFields: ["cert_import_complete"],
+            });
+        this.client.on("sync", this.onSync);
+    }
+    async onSync(event) {
+        const {
+            data: {deleted, current},
+        } = event;
+
+        if (!Services.prefs.getBoolPref(INTERMEDIATES_ENABLED_PREF, true)) {
+          return;
+        }
+
+        const col = await this.client.openCollection();
+
+        this.removeCerts(deleted);
+
+        // Bug 1429800: once the CertStateService has the correct interface, also
+        // store the whitelist status and crlite enrollment status
+
+        // Download attachments that are awaiting download, up to a max.
+        const maxDownloadsPerRun = 100;
+
+        // 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)
+
+        let waiting = current.filter(record => !record.cert_import_complete);
+        await Promise.all(waiting.slice(0, maxDownloadsPerRun)
+          .map(record => this.maybeDownloadAttachment(record, col))
+        );
+
+        // Bug 1519273 - Log telemetry after a sync
+    }
+
+    /**
+     * Downloads the attachment data of the given record. Does not retry,
+     * leaving that to the caller.
+     * @param  {AttachmentRecord} record The data to obtain
+     * @return {Promise}          resolves to a Uint8Array on success
+     */
+    async _downloadAttachmentBytes(record) {
+      const {attachment: {location}} = record;
+      const remoteFilePath = (await baseAttachmentsURL) + location;
+      const headers = new Headers();
+      headers.set("Accept-Encoding", "gzip");
+
+      return fetch(remoteFilePath, {headers})
+      .then(resp => {
+        if (!resp.ok) {
+          Cu.reportError(`Failed to fetch ${remoteFilePath}: ${resp.status}`);
+          return Promise.reject();
+        }
+        return resp.arrayBuffer();
+      })
+      .then(buffer => new Uint8Array(buffer));
+    }
+
+    /**
+     * 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 Cu.reportError; If you need to know
+     * success/failure, check record.cert_import_complete.
+     * @param  {AttachmentRecord} record defines which data to obtain
+     * @param  {KintoCollection}  col The kinto collection to update
+     * @return {Promise}          a Promise representing the transaction
+     */
+    async maybeDownloadAttachment(record, col) {
+      const {attachment: {hash, size}} = record;
+
+      return this._downloadAttachmentBytes(record)
+      .then(function(attachmentData) {
+        if (!attachmentData || attachmentData.length == 0) {
+          // Bug 1519273 - Log telemetry for these rejections
+          return Promise.reject();
+        }
+
+        // check the length
+        if (attachmentData.length !== size) {
+          return Promise.reject();
+        }
+
+        // check the hash
+        let dataAsString = gTextDecoder.decode(attachmentData);
+        let calculatedHash = getHash(dataAsString);
+        if (calculatedHash !== hash) {
+          return Promise.reject();
+        }
+
+        // split off the header and footer, base64 decode, construct the cert
+        // from the resulting DER data.
+        let b64data = dataAsString.split("-----")[2].replace(/\s/g, "");
+        let certDer = atob(b64data);
+
+        // We can assume that roots obtained from remote-settings are part of
+        // the root program. If they aren't, they won't be used for path-
+        // building or have trust anyway, so just add it to the DB.
+        certdb.addCert(certDer, ",,");
+
+        record.cert_import_complete = true;
+        return col.update(record);
+      })
+      .catch(() => {
+        // Don't abort the outer Promise.all because of an error. Errors were
+        // sent using Cu.reportError()
+      });
+    }
+
+    async maybeSync(expectedTimestamp, options) {
+      return this.client.maybeSync(expectedTimestamp, options);
+    }
+
+    // Note that removing certificates from the DB will likely not have an
+    // effect until restart.
+    async removeCerts(records) {
+      let recordsToRemove = records;
+
+      for (let cert of certdb.getCerts().getEnumerator()) {
+        let certHash = stripColons(cert.sha256Fingerprint);
+        for (let i = 0; i < recordsToRemove.length; i++) {
+          let record = recordsToRemove[i];
+          if (record.pubKeyHash == certHash) {
+            certdb.deleteCertificate(cert);
+            recordsToRemove.splice(i, 1);
+            break;
+          }
+        }
+      }
+
+      if (recordsToRemove.length > 0) {
+        Cu.reportError(`Failed to remove ${recordsToRemove.length} intermediate certificates`);
+      }
+    }
+};
+
+const EXPORTED_SYMBOLS = ["RemoteSecuritySettings"];
--- a/security/manager/ssl/moz.build
+++ b/security/manager/ssl/moz.build
@@ -50,19 +50,24 @@ if CONFIG['MOZ_XUL']:
     ]
 
 XPIDL_MODULE = 'pipnss'
 
 # These aren't actually used in production code yet, so we don't want to
 # ship them with the browser.
 TESTING_JS_MODULES.psm += [
     'DER.jsm',
+    'RemoteSecuritySettings.jsm',
     'X509.jsm',
 ]
 
+# These are JS Modules that are to be used in production.
+EXTRA_JS_MODULES.psm += [
+]
+
 EXPORTS += [
     'CryptoTask.h',
     'nsClientAuthRemember.h',
     'nsNSSCallbacks.h',
     'nsNSSCertificate.h',
     'nsNSSComponent.h',
     'nsNSSHelper.h',
     'nsRandomGenerator.h',
--- a/security/manager/ssl/security-prefs.js
+++ b/security/manager/ssl/security-prefs.js
@@ -147,8 +147,15 @@ pref("security.pki.mitm_canary_issuer.en
 
 // It is set to true when a non-built-in root certificate is detected on a
 // Firefox update service's connection.
 // This value is set automatically.
 // The difference between security.pki.mitm_canary_issuer and this pref is that
 // here the root is trusted but not a built-in, whereas for
 // security.pki.mitm_canary_issuer.enabled, the root is not trusted.
 pref("security.pki.mitm_detected", false);
+
+// Intermediate CA Preloading settings
+pref("security.remote_settings.intermediates.enabled", false);
+pref("security.remote_settings.intermediates.bucket", "security-state");
+pref("security.remote_settings.intermediates.collection", "intermediates");
+pref("security.remote_settings.intermediates.checked", 0);
+pref("security.remote_settings.intermediates.signer", "onecrl.content-signature.mozilla.org");
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads.js
@@ -0,0 +1,437 @@
+
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+// 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/.
+
+"use strict";
+do_get_profile(); // must be called before getting nsIX509CertDB
+
+const { RemoteSecuritySettings } = ChromeUtils.import("resource://testing-common/psm/RemoteSecuritySettings.jsm", {});
+
+let remoteSecSetting = new RemoteSecuritySettings();
+let server;
+
+let intermediate1Data;
+let intermediate2Data;
+
+let currentTime = 0;
+
+function cyclingIteratorGenerator(items, count = null) {
+  return () => cyclingIterator(items, count);
+}
+
+function* cyclingIterator(items, count = null) {
+  if (count == null) {
+    count = items.length;
+  }
+  for (let i = 0; i < count; i++) {
+    yield items[i % items.length];
+  }
+}
+
+function getTime() {
+  currentTime = currentTime + 1000 * 60 * 60 * 12;
+  return currentTime;
+}
+
+function getHash(aStr) {
+  let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+  hasher.init(Ci.nsICryptoHash.SHA256);
+  let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream);
+  stringStream.data = aStr;
+  hasher.updateFromStream(stringStream, -1);
+
+  // convert the binary hash data to a hex string.
+  return hasher.finish(true);
+}
+
+function setupKintoPreloadServer(certGenerator, options = {
+  attachmentCB: null,
+  hashFunc: null,
+  lengthFunc: null,
+}) {
+  const dummyServerURL = `http://localhost:${server.identity.primaryPort}/v1`;
+  Services.prefs.setCharPref("services.settings.server", dummyServerURL);
+  Services.prefs.setBoolPref("services.settings.verify_signature", false);
+
+  const configPath = "/v1/";
+  const recordsPath = "/v1/buckets/security-state/collections/intermediates/records";
+  const attachmentsPath = "/attachments/";
+
+  if (options.hashFunc == null) {
+    options.hashFunc = getHash;
+  }
+  if (options.lengthFunc == null) {
+    options.lengthFunc = arr => arr.length;
+  }
+
+  function setHeader(response, headers) {
+    for (let headerLine of headers) {
+      let headerElements = headerLine.split(":");
+      response.setHeader(headerElements[0], headerElements[1].trimLeft());
+    }
+    response.setHeader("Date", (new Date()).toUTCString());
+  }
+
+  // Basic server information, all static
+  server.registerPathHandler(configPath, (request, response) => {
+    try {
+      const respData = getResponseData(request, server.identity.primaryPort);
+      if (!respData) {
+        do_throw(`unexpected ${request.method} request for ${request.path}?${request.queryString}`);
+        return;
+      }
+
+      response.setStatusLine(null, respData.status.status,
+                             respData.status.statusText);
+      setHeader(response, respData.responseHeaders);
+      response.write(respData.responseBody);
+    } catch (e) {
+      info(e);
+    }
+  });
+
+  // Lists of certs
+  server.registerPathHandler(recordsPath, (request, response) => {
+    response.setStatusLine(null, 200, "OK");
+    setHeader(response, [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"1000\"",
+    ]);
+
+    let output = [];
+    let count = 1;
+    let certDB = Cc["@mozilla.org/security/x509certdb;1"]
+                 .getService(Ci.nsIX509CertDB);
+
+    let certIterator = certGenerator();
+    let result = certIterator.next();
+    while (!result.done) {
+      let certBytes = result.value;
+      let cert = certDB.constructX509FromBase64(pemToBase64(certBytes));
+
+      output.push({
+        "details": {
+          "who": "",
+          "why": "",
+          "name": "",
+          "created": "",
+        },
+        "subject": "",
+        "attachment": {
+          "hash": options.hashFunc(certBytes),
+          "size": options.lengthFunc(certBytes),
+          "filename": `intermediate certificate #${count}.pem`,
+          "location": `int${count}`,
+          "mimetype": "application/x-pem-file",
+        },
+        "whitelist": false,
+        "pubKeyHash": cert.sha256Fingerprint,
+        "crlite_enrolled": "true",
+        "id": `78cf8900-fdea-4ce5-f8fb-${count}`,
+        "last_modified": 1000,
+      });
+
+      count++;
+      result = certIterator.next();
+    }
+
+    response.write(JSON.stringify({ data: output }));
+  });
+
+  // Certificate data
+  server.registerPrefixHandler(attachmentsPath, (request, response) => {
+    setHeader(response, [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/x-pem-file; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"1000\"",
+    ]);
+
+    let identifier = request.path.match(/\d+$/)[0];
+    let count = 1;
+
+    let certIterator = certGenerator();
+    let result = certIterator.next();
+    while (!result.done) {
+      // Could do the modulus of the certIterator to get the right data,
+      // but that requires plumbing through knowledge of those offsets, so
+      // let's just loop. It's not that slow.
+
+      if (count == identifier) {
+        response.setStatusLine(null, 200, "OK");
+        response.write(result.value);
+        if (options.attachmentCB) {
+          options.attachmentCB(identifier, true);
+        }
+        return;
+      }
+
+      count++;
+      result = certIterator.next();
+    }
+
+    response.setStatusLine(null, 404, `Identifier ${identifier} Not Found`);
+    if (options.attachmentCB) {
+      options.attachmentCB(identifier, false);
+    }
+  });
+}
+
+add_task(async function test_preload_empty() {
+  Services.prefs.setBoolPref("security.remote_settings.intermediates.enabled", true);
+
+  let countDownloadAttempts = 0;
+  setupKintoPreloadServer(
+    cyclingIteratorGenerator([]),
+    found => { countDownloadAttempts++; }
+  );
+
+  let certDB = Cc["@mozilla.org/security/x509certdb;1"]
+               .getService(Ci.nsIX509CertDB);
+
+  // load the first root and end entity, ignore the initial intermediate
+  addCertFromFile(certDB, "test_intermediate_preloads/ca.pem", "CTu,,");
+
+  let ee_cert = constructCertFromFile("test_intermediate_preloads/ee.pem");
+  notEqual(ee_cert, null, "EE cert should have successfully loaded");
+
+  // sync to the kinto server.
+  await remoteSecSetting.maybeSync(getTime());
+
+  Assert.equal(countDownloadAttempts, 0, "There should have been no downloads");
+
+  // check that ee cert 1 is unknown
+  await checkCertErrorGeneric(certDB, ee_cert, SEC_ERROR_UNKNOWN_ISSUER,
+                              certificateUsageSSLServer);
+});
+
+add_task(async function test_preload_disabled() {
+  Services.prefs.setBoolPref("security.remote_settings.intermediates.enabled", false);
+
+  let countDownloadAttempts = 0;
+  setupKintoPreloadServer(
+    cyclingIteratorGenerator([intermediate1Data]),
+    {attachmentCB: (identifier, attachmentFound) => { countDownloadAttempts++; }}
+  );
+
+  // sync to the kinto server.
+  await remoteSecSetting.maybeSync(getTime());
+
+  Assert.equal(countDownloadAttempts, 0, "There should have been no downloads");
+});
+
+add_task(async function test_preload_invalid_hash() {
+  Services.prefs.setBoolPref("security.remote_settings.intermediates.enabled", true);
+
+  let countDownloadAttempts = 0;
+  setupKintoPreloadServer(
+    cyclingIteratorGenerator([intermediate1Data]),
+    {
+      attachmentCB: (identifier, attachmentFound) => { countDownloadAttempts++; },
+      hashFunc: data => "invalidHash",
+    }
+  );
+
+  // sync to the kinto server.
+  await remoteSecSetting.maybeSync(getTime());
+
+  Assert.equal(countDownloadAttempts, 1, "There should have been one download attempt");
+
+  let certDB = Cc["@mozilla.org/security/x509certdb;1"]
+               .getService(Ci.nsIX509CertDB);
+
+  // load the first root and end entity, ignore the initial intermediate
+  addCertFromFile(certDB, "test_intermediate_preloads/ca.pem", "CTu,,");
+
+  let ee_cert = constructCertFromFile("test_intermediate_preloads/ee.pem");
+  notEqual(ee_cert, null, "EE cert should have successfully loaded");
+
+  // We should still have a missing intermediate.
+  await checkCertErrorGeneric(certDB, ee_cert, SEC_ERROR_UNKNOWN_ISSUER,
+                              certificateUsageSSLServer);
+});
+
+add_task(async function test_preload_invalid_length() {
+  Services.prefs.setBoolPref("security.remote_settings.intermediates.enabled", true);
+
+  let countDownloadAttempts = 0;
+  setupKintoPreloadServer(
+    cyclingIteratorGenerator([intermediate1Data]),
+    {
+      attachmentCB: (identifier, attachmentFound) => { countDownloadAttempts++; },
+      lengthFunc: data => 42,
+    }
+  );
+
+  // sync to the kinto server.
+  await remoteSecSetting.maybeSync(getTime());
+
+  Assert.equal(countDownloadAttempts, 1, "There should have been one download attempt");
+
+  let certDB = Cc["@mozilla.org/security/x509certdb;1"]
+               .getService(Ci.nsIX509CertDB);
+
+  // load the first root and end entity, ignore the initial intermediate
+  addCertFromFile(certDB, "test_intermediate_preloads/ca.pem", "CTu,,");
+
+  let ee_cert = constructCertFromFile("test_intermediate_preloads/ee.pem");
+  notEqual(ee_cert, null, "EE cert should have successfully loaded");
+
+  // We should still have a missing intermediate.
+  await checkCertErrorGeneric(certDB, ee_cert, SEC_ERROR_UNKNOWN_ISSUER,
+                              certificateUsageSSLServer);
+});
+
+add_task(async function test_preload_basic() {
+  Services.prefs.setBoolPref("security.remote_settings.intermediates.enabled", true);
+
+  let countDownloadAttempts = 0;
+  setupKintoPreloadServer(
+    cyclingIteratorGenerator([intermediate1Data, intermediate2Data]),
+    {attachmentCB: (identifier, attachmentFound) => { countDownloadAttempts++; }}
+  );
+
+  let certDB = Cc["@mozilla.org/security/x509certdb;1"]
+               .getService(Ci.nsIX509CertDB);
+
+  // load the first root and end entity, ignore the initial intermediate
+  addCertFromFile(certDB, "test_intermediate_preloads/ca.pem", "CTu,,");
+
+  let ee_cert = constructCertFromFile("test_intermediate_preloads/ee.pem");
+  notEqual(ee_cert, null, "EE cert should have successfully loaded");
+
+  // load the second end entity, ignore both intermediate and root
+  let ee_cert_2 = constructCertFromFile("test_intermediate_preloads/ee2.pem");
+  notEqual(ee_cert_2, null, "EE cert 2 should have successfully loaded");
+
+  // check that the missing intermediate causes an unknown issuer error, as
+  // expected, in both cases
+  await checkCertErrorGeneric(certDB, ee_cert, SEC_ERROR_UNKNOWN_ISSUER,
+                              certificateUsageSSLServer);
+  await checkCertErrorGeneric(certDB, ee_cert_2, SEC_ERROR_UNKNOWN_ISSUER,
+                              certificateUsageSSLServer);
+
+  // sync to the kinto server.
+  await remoteSecSetting.maybeSync(getTime());
+
+  Assert.equal(countDownloadAttempts, 2, "There should have been 2 downloads");
+
+  // check that ee cert 1 verifies now the update has happened and there is
+  // an intermediate
+  await checkCertErrorGeneric(certDB, ee_cert, PRErrorCodeSuccess,
+                              certificateUsageSSLServer);
+
+  // check that ee cert 2 does not verify - since we don't know the issuer of
+  // this certificate
+  await checkCertErrorGeneric(certDB, ee_cert_2, SEC_ERROR_UNKNOWN_ISSUER,
+                              certificateUsageSSLServer);
+});
+
+
+add_task(async function test_preload_200() {
+  Services.prefs.setBoolPref("security.remote_settings.intermediates.enabled", true);
+
+  let countDownloadedAttachments = 0;
+  let countMissingAttachments = 0;
+  setupKintoPreloadServer(
+    cyclingIteratorGenerator([intermediate1Data, intermediate2Data], 200),
+    {
+      attachmentCB: (identifier, attachmentFound) => {
+        if (!attachmentFound) {
+          countMissingAttachments++;
+        } else {
+          countDownloadedAttachments++;
+        }
+      },
+    }
+  );
+
+  // sync to the kinto server.
+  await remoteSecSetting.maybeSync(getTime());
+
+  Assert.equal(countMissingAttachments, 0, "There should have been no missing attachments");
+  Assert.equal(countDownloadedAttachments, 100, "There should have been only 100 downloaded");
+
+  // sync to the kinto server again
+  await remoteSecSetting.maybeSync(getTime());
+
+  await Promise.resolve();
+
+  Assert.equal(countMissingAttachments, 0, "There should have been no missing attachments");
+  Assert.equal(countDownloadedAttachments, 198, "There should have been now 198 downloaded, because 2 existed in an earlier test");
+});
+
+
+function run_test() {
+  // Ensure that signature verification is disabled to prevent interference
+  // with basic certificate sync tests
+  Services.prefs.setBoolPref("services.blocklist.signing.enforced", false);
+
+  let intermediate1File = do_get_file("test_intermediate_preloads/int.pem", false);
+  intermediate1Data = readFile(intermediate1File);
+
+  let intermediate2File = do_get_file("test_intermediate_preloads/int2.pem", false);
+  intermediate2Data = readFile(intermediate2File);
+
+  // Set up an HTTP Server
+  server = new HttpServer();
+  server.start(-1);
+
+  run_next_test();
+
+  registerCleanupFunction(function() {
+    server.stop(() => { });
+  });
+}
+
+// get a response for a given request from sample data
+function getResponseData(req, port) {
+  info(`Resource requested: ${req.method}:${req.path}?${req.queryString}\n\n`);
+  const cannedResponses = {
+    "OPTIONS": {
+      "responseHeaders": [
+        "Access-Control-Allow-Headers: Content-Length,Expires,Backoff,Retry-After,Last-Modified,Total-Records,ETag,Pragma,Cache-Control,authorization,content-type,if-none-match,Alert,Next-Page",
+        "Access-Control-Allow-Methods: GET,HEAD,OPTIONS,POST,DELETE,OPTIONS",
+        "Access-Control-Allow-Origin: *",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": "null",
+    },
+    "GET:/v1/": {
+      "responseHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+      ],
+      "status": {status: 200, statusText: "OK"},
+      "responseBody": JSON.stringify({
+        "settings": {
+          "batch_max_requests": 25,
+        },
+        "url": `http://localhost:${port}/v1/`,
+        "documentation": "https://kinto.readthedocs.org/",
+        "version": "1.5.1",
+        "commit": "cbc6f58",
+        "hello": "kinto",
+        "capabilities": {
+          "attachments": {
+            "base_url": `http://localhost:${port}/attachments/`,
+          },
+        },
+      }),
+    },
+  };
+  let result = cannedResponses[`${req.method}:${req.path}?${req.queryString}`] ||
+               cannedResponses[`${req.method}:${req.path}`] ||
+               cannedResponses[req.method];
+  return result;
+}
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/ca.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC+TCCAeGgAwIBAgIUN/Y56TvJcL2liqk2Feh/QfKrlLwwDQYJKoZIhvcNAQEL
+BQAwJTEjMCEGA1UEAwwaaW50ZXJtZWRpYXRlLXByZWxvYWRpbmctY2EwIhgPMjAx
+MDAxMDEwMDAwMDBaGA8yMDUwMDEwMTAwMDAwMFowJTEjMCEGA1UEAwwaaW50ZXJt
+ZWRpYXRlLXByZWxvYWRpbmctY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
+AoIBAQC6iFGoRI4W1kH9braIBjYQPTwT2erkNUq07PVoV2wke8HHJajg2B+9sZwG
+m24ahvJr4q9adWtqZHEIeqVap0WH9xzVJJwCfs1D/B5p0DggKZOrIMNJ5Nu5TMJr
+bA7tFYIP8X6taRqx0wI6iypB7qdw4A8Njf1mCyuwJJKkfbmIYXmQsVeQPdI7xeC4
+SB+oN9OIQ+8nFthVt2Zaqn4CkC86exCABiTMHGyXrZZhW7filhLAdTGjDJHdtMr3
+/K0dJdMJ77kXDqdo4bN7LyJvaeO0ipVhHe4m1iWdq5EITjbLHCQELL8Wiy/l8Y+Z
+FzG4s/5JI/pyUcQx1QOs2hgKNe2NAgMBAAGjHTAbMAwGA1UdEwQFMAMBAf8wCwYD
+VR0PBAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IBAQBSPwr2BfSHT3saxwx6YGEautZx
+w/sdM9AJAubFLqDd3MYHtzCZcQXaeDGbAzvo8m/PKA4Yt+UYbKyDnRR8sLA4f/iu
+z1zHeenlzBWpRVHu/++ZSk/ESwn0zLprIsOcXjaYkbfrqcEGNWvLJzpT4T36Gr9t
+DvxHnpsaMsJviZS3WHzTSoioWkcRyF78bYa51ZJWYJHFKZQppqhJ+jcoJhiomRlc
+WwhI8NAU3dOOFJuEg/z+vQpcEQi0rRW9J6X/15BUZRQlF5Hs2wilGa8ViNX2+B5I
+kjbmNrdT5hcnGEfR7JpHFuihFdxQc4CFY87u1chI8yaHLhhriUP6Jq0+J5ur
+-----END CERTIFICATE-----
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/ca.pem.certspec
@@ -0,0 +1,5 @@
+issuer:intermediate-preloading-ca
+subject:intermediate-preloading-ca
+validity:20100101-20500101
+extension:basicConstraints:cA,
+extension:keyUsage:keyCertSign,cRLSign
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/ee.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC5TCCAc2gAwIBAgIUKWcVgS9ewqHTZ/zYT3TVfz5E3GEwDQYJKoZIhvcNAQEL
+BQAwLzEtMCsGA1UEAwwkaW50ZXJtZWRpYXRlLXByZWxvYWRpbmctaW50ZXJtZWRp
+YXRlMCIYDzIwMTcxMTI3MDAwMDAwWhgPMjAyMDAyMDUwMDAwMDBaMA0xCzAJBgNV
+BAMMAmVlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuohRqESOFtZB
+/W62iAY2ED08E9nq5DVKtOz1aFdsJHvBxyWo4NgfvbGcBptuGobya+KvWnVramRx
+CHqlWqdFh/cc1SScAn7NQ/weadA4ICmTqyDDSeTbuUzCa2wO7RWCD/F+rWkasdMC
+OosqQe6ncOAPDY39ZgsrsCSSpH25iGF5kLFXkD3SO8XguEgfqDfTiEPvJxbYVbdm
+Wqp+ApAvOnsQgAYkzBxsl62WYVu34pYSwHUxowyR3bTK9/ytHSXTCe+5Fw6naOGz
+ey8ib2njtIqVYR3uJtYlnauRCE42yxwkBCy/Fosv5fGPmRcxuLP+SSP6clHEMdUD
+rNoYCjXtjQIDAQABoxcwFTATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B
+AQsFAAOCAQEAfeCXPOT0iBdAdMMQsRLX/T4PjORcHCJE679gaaCH408zDnR5y4N1
+hXYhIqePF64Xj/CItUBjF5H29K0lsitkwAd5R0kdKo58ieBPAuo940u/aTgSWteb
+clDWDn2b3ACL+6N1nCB4t10yCq0xiUG0KYEntea7fNVoyzJCuo2BA+cZn+zdV8e4
+/bsG2oasvp3rAo72P+8fyhBdwJnL/pltAg+SgyyEDFrWhQwWcrLaq30AP0w0ItO+
+ctgVqlXDsWyVOTtxGKX7l6aoIyDZ8ypYKT/vDBdRD98kJO8JTLCB9D/+fqNIZ+RR
+oCsZYzAGCIQJQMy8AG3nELNnugutqPAarg==
+-----END CERTIFICATE-----
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/ee.pem.certspec
@@ -0,0 +1,3 @@
+issuer:intermediate-preloading-intermediate
+subject:ee
+extension:extKeyUsage:serverAuth
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/ee2.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC5zCCAc+gAwIBAgIUP/k97g9sNmKzpKUGr6rLH9tuefIwDQYJKoZIhvcNAQEL
+BQAwMDEuMCwGA1UEAwwlaW50ZXJtZWRpYXRlLXByZWxvYWRpbmctaW50ZXJtZWRp
+YXRlMjAiGA8yMDE3MTEyNzAwMDAwMFoYDzIwMjAwMjA1MDAwMDAwWjAOMQwwCgYD
+VQQDDANlZTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6iFGoRI4W
+1kH9braIBjYQPTwT2erkNUq07PVoV2wke8HHJajg2B+9sZwGm24ahvJr4q9adWtq
+ZHEIeqVap0WH9xzVJJwCfs1D/B5p0DggKZOrIMNJ5Nu5TMJrbA7tFYIP8X6taRqx
+0wI6iypB7qdw4A8Njf1mCyuwJJKkfbmIYXmQsVeQPdI7xeC4SB+oN9OIQ+8nFthV
+t2Zaqn4CkC86exCABiTMHGyXrZZhW7filhLAdTGjDJHdtMr3/K0dJdMJ77kXDqdo
+4bN7LyJvaeO0ipVhHe4m1iWdq5EITjbLHCQELL8Wiy/l8Y+ZFzG4s/5JI/pyUcQx
+1QOs2hgKNe2NAgMBAAGjFzAVMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0GCSqGSIb3
+DQEBCwUAA4IBAQCvme71ToYWZRNZWpv3ug9kcH4//B5rmQvPRqJjf+TtBsO284MN
+avtknmZBkTOUtZvRIBZxa736s7pgtrs+3cx8K6ssyLh05fWl+FCtKyesu57njxb5
+Y6sNHh8AYkoC9RYFUHiQBZqW8KJENN57aP7iXffB9oQx7BNSv82Qmtb93YwDYmOu
+9UTKiIuvWJiu7wMOGllRzQ42F2E4V9HWTSEmsdyw7RjAknMZt34DU0GeAW18K+G9
+VYpXSTPiHcVYn9inuoWO1n7h84oJwJYx23sN9q79fcDGUWbNR4QqZ90yHBxw75Ts
+OSXMtREbIRZW5NnOc2fqwPgRootdyLQhoWuu
+-----END CERTIFICATE-----
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/ee2.pem.certspec
@@ -0,0 +1,3 @@
+issuer:intermediate-preloading-intermediate2
+subject:ee2
+extension:extKeyUsage:serverAuth
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/int.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDAzCCAeugAwIBAgIUTXfPu5ok43b8qQI8ttEhSBy8GA0wDQYJKoZIhvcNAQEL
+BQAwJTEjMCEGA1UEAwwaaW50ZXJtZWRpYXRlLXByZWxvYWRpbmctY2EwIhgPMjAx
+NzExMjcwMDAwMDBaGA8yMDIwMDIwNTAwMDAwMFowLzEtMCsGA1UEAwwkaW50ZXJt
+ZWRpYXRlLXByZWxvYWRpbmctaW50ZXJtZWRpYXRlMIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAuohRqESOFtZB/W62iAY2ED08E9nq5DVKtOz1aFdsJHvB
+xyWo4NgfvbGcBptuGobya+KvWnVramRxCHqlWqdFh/cc1SScAn7NQ/weadA4ICmT
+qyDDSeTbuUzCa2wO7RWCD/F+rWkasdMCOosqQe6ncOAPDY39ZgsrsCSSpH25iGF5
+kLFXkD3SO8XguEgfqDfTiEPvJxbYVbdmWqp+ApAvOnsQgAYkzBxsl62WYVu34pYS
+wHUxowyR3bTK9/ytHSXTCe+5Fw6naOGzey8ib2njtIqVYR3uJtYlnauRCE42yxwk
+BCy/Fosv5fGPmRcxuLP+SSP6clHEMdUDrNoYCjXtjQIDAQABox0wGzAMBgNVHRME
+BTADAQH/MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAEZc5zgkFof6G
+/r2xV3++hfvwfMe2QwaDfb0fyyq2kLgDxT3c/AIWkSYw5WM3EplS7jNWeBPUEcQD
+cCuY7D34sVEFmmmY1rSd7BmVJMQ8dDkEr/v1wu+uDveRJIhDUpGRWPuml5AJUmR7
+qCZonfvtj5C5EkWX6LiqSf39RCWedShr9z8C68qdVmexD40svIet3SP0SJDrurOm
+KLJ0e7sw2jx19O6A3P7SB9cHsRUET2BRqR0BeHVqrWxHbujCPA2odKX+cA89Q1PF
+cUc5V9SWv6fHVr1Gy2hchPhBPY2Zu5gsn1AWpo+s5jMrbU4gfLiAufH4kqpeeFwc
+xG04TgZ9VA==
+-----END CERTIFICATE-----
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/int.pem.certspec
@@ -0,0 +1,4 @@
+issuer:intermediate-preloading-ca
+subject:intermediate-preloading-intermediate
+extension:basicConstraints:cA,
+extension:keyUsage:keyCertSign,cRLSign
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/int2.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDBTCCAe2gAwIBAgIUIx42GCoUnPyFRd6qXriQSLixdjMwDQYJKoZIhvcNAQEL
+BQAwJjEkMCIGA1UEAwwbaW50ZXJtZWRpYXRlLXByZWxvYWRpbmctY2EyMCIYDzIw
+MTcxMTI3MDAwMDAwWhgPMjAyMDAyMDUwMDAwMDBaMDAxLjAsBgNVBAMMJWludGVy
+bWVkaWF0ZS1wcmVsb2FkaW5nLWludGVybWVkaWF0ZTIwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQC6iFGoRI4W1kH9braIBjYQPTwT2erkNUq07PVoV2wk
+e8HHJajg2B+9sZwGm24ahvJr4q9adWtqZHEIeqVap0WH9xzVJJwCfs1D/B5p0Dgg
+KZOrIMNJ5Nu5TMJrbA7tFYIP8X6taRqx0wI6iypB7qdw4A8Njf1mCyuwJJKkfbmI
+YXmQsVeQPdI7xeC4SB+oN9OIQ+8nFthVt2Zaqn4CkC86exCABiTMHGyXrZZhW7fi
+lhLAdTGjDJHdtMr3/K0dJdMJ77kXDqdo4bN7LyJvaeO0ipVhHe4m1iWdq5EITjbL
+HCQELL8Wiy/l8Y+ZFzG4s/5JI/pyUcQx1QOs2hgKNe2NAgMBAAGjHTAbMAwGA1Ud
+EwQFMAMBAf8wCwYDVR0PBAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IBAQBjFei0ALlP
+HDJMW6ARi4cKunZ2FML0lkG+R9b92j5pWTvDy+1UZw4sZXMAFcujzs6mMNYL4/yO
+k5weX7c6hZkVTcZsqQHgnyvEwF80OEgmFpBQiDECsULnKF+Pu5rfeQmrJLBIB1pC
+VnF5C9BhkZGHW3Ic+U/r+0k8UKE5hpZ7QtNtyLdKDWIdD90hCBonhX3Jjk9NtY+T
+1qzGKg6Ldn2osLlUpJWZB6TOui62hoJvIBD14yVDVy3mPT/L5ueZ7GqpiqRh9qyG
+U2GmvnpAGGi28dUpScykXnONlhg45YXGm1usLcVFMRcU8DcekdXKQgCITbtQUg2i
+TV3KCNEAdyn8
+-----END CERTIFICATE-----
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/int2.pem.certspec
@@ -0,0 +1,4 @@
+issuer:intermediate-preloading-ca2
+subject:intermediate-preloading-intermediate2
+extension:basicConstraints:cA,
+extension:keyUsage:keyCertSign,cRLSign
new file mode 100644
--- /dev/null
+++ b/security/manager/ssl/tests/unit/test_intermediate_preloads/moz.build
@@ -0,0 +1,17 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+# Temporarily disabled. See bug 1256495.
+#test_certificates = (
+#    'ca.pem',
+#    'ee.pem',
+#    'ee2.pem',
+#    'int.pem',
+#    'int2.pem',
+#)
+#
+#for test_certificate in test_certificates:
+#    GeneratedTestCertificate(test_certificate)
--- a/security/manager/ssl/tests/unit/xpcshell.ini
+++ b/security/manager/ssl/tests/unit/xpcshell.ini
@@ -17,16 +17,17 @@ support-files =
   test_cert_version/**
   test_cert_utf8/**
   test_certDB_import/**
   test_certviewer_invalid_oids/**
   test_content_signing/**
   test_ct/**
   test_ev_certs/**
   test_intermediate_basic_usage_constraints/**
+  test_intermediate_preloads/**
   test_keysize/**
   test_keysize_ev/**
   test_missing_intermediate/**
   test_name_constraints/**
   test_ocsp_url/**
   test_onecrl/**
   test_pinning_dynamic/**
   test_sdr_preexisting/**
@@ -94,16 +95,17 @@ run-sequentially = hardcoded ports
 [test_forget_about_site_security_headers.js]
 skip-if = toolkit == 'android'
 [test_hash_algorithms.js]
 [test_hash_algorithms_wrap.js]
 # bug 1124289 - run_test_in_child violates the sandbox on android
 skip-if = toolkit == 'android'
 [test_hmac.js]
 [test_intermediate_basic_usage_constraints.js]
+[test_intermediate_preloads.js]
 [test_imminent_distrust.js]
 run-sequentially = hardcoded ports
 [test_js_cert_override_service.js]
 run-sequentially = hardcoded ports
 [test_keysize.js]
 [test_keysize_ev.js]
 run-sequentially = hardcoded ports
 [test_local_cert.js]