Bug 1263602 - Verify kinto collection signatures using the content signature service. r=MattN, r=leplatrem
authorMark Goodwin <mgoodwin@mozilla.com>
Thu, 02 Jun 2016 09:01:26 +0100
changeset 339119 b83a48d5fab9a296f1522b7e00d31bcb5eb102d4
parent 339118 c62262afd761e9936348b2e30fb82adf7e1ecc74
child 339120 4a84c4e71f09c49947381b3b9a8551ab6f10c5f8
push id6249
push userjlund@mozilla.com
push dateMon, 01 Aug 2016 13:59:36 +0000
treeherdermozilla-beta@bad9d4f5bf7e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN, leplatrem
bugs1263602
milestone49.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 1263602 - Verify kinto collection signatures using the content signature service. r=MattN, r=leplatrem MozReview-Commit-ID: J6fuSDaW1JR
modules/libpref/init/all.js
services/common/blocklist-clients.js
services/common/tests/moz.build
services/common/tests/unit/moz.build
services/common/tests/unit/test_blocklist_certificates.js
services/common/tests/unit/test_blocklist_signatures.js
services/common/tests/unit/test_blocklist_signatures/collection_signing_ee.pem.certspec
services/common/tests/unit/test_blocklist_signatures/collection_signing_int.pem.certspec
services/common/tests/unit/test_blocklist_signatures/collection_signing_root.pem.certspec
services/common/tests/unit/test_blocklist_signatures/moz.build
services/common/tests/unit/xpcshell.ini
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -2114,16 +2114,20 @@ pref("services.blocklist.onecrl.collecti
 pref("services.blocklist.onecrl.checked", 0);
 pref("services.blocklist.addons.collection", "addons");
 pref("services.blocklist.addons.checked", 0);
 pref("services.blocklist.plugins.collection", "plugins");
 pref("services.blocklist.plugins.checked", 0);
 pref("services.blocklist.gfx.collection", "gfx");
 pref("services.blocklist.gfx.checked", 0);
 
+// Controls whether signing should be enforced on signature-capable blocklist
+// collections.
+pref("services.blocklist.signing.enforced", false);
+
 // For now, let's keep settings server update out of the release builds
 #ifdef RELEASE_BUILD
 pref("services.blocklist.update_enabled", false);
 pref("security.onecrl.via.amo", true);
 #else
 pref("services.blocklist.update_enabled", true);
 pref("security.onecrl.via.amo", false);
 #endif
--- a/services/common/blocklist-clients.js
+++ b/services/common/blocklist-clients.js
@@ -12,34 +12,80 @@ this.EXPORTED_SYMBOLS = ["AddonBlocklist
                          "FILENAME_GFX_JSON",
                          "FILENAME_PLUGINS_JSON"];
 
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 const { Task } = Cu.import("resource://gre/modules/Task.jsm");
 const { OS } = Cu.import("resource://gre/modules/osfile.jsm");
+Cu.importGlobalProperties(["fetch"]);
 
 const { loadKinto } = Cu.import("resource://services-common/kinto-offline-client.js");
+const { KintoHttpClient } = Cu.import("resource://services-common/kinto-http-client.js");
+const { CanonicalJSON } = Components.utils.import("resource://gre/modules/CanonicalJSON.jsm");
 
 const PREF_SETTINGS_SERVER                   = "services.settings.server";
 const PREF_BLOCKLIST_BUCKET                  = "services.blocklist.bucket";
 const PREF_BLOCKLIST_ONECRL_COLLECTION       = "services.blocklist.onecrl.collection";
 const PREF_BLOCKLIST_ONECRL_CHECKED_SECONDS  = "services.blocklist.onecrl.checked";
 const PREF_BLOCKLIST_ADDONS_COLLECTION       = "services.blocklist.addons.collection";
 const PREF_BLOCKLIST_ADDONS_CHECKED_SECONDS  = "services.blocklist.addons.checked";
 const PREF_BLOCKLIST_PLUGINS_COLLECTION      = "services.blocklist.plugins.collection";
 const PREF_BLOCKLIST_PLUGINS_CHECKED_SECONDS = "services.blocklist.plugins.checked";
 const PREF_BLOCKLIST_GFX_COLLECTION          = "services.blocklist.gfx.collection";
 const PREF_BLOCKLIST_GFX_CHECKED_SECONDS     = "services.blocklist.gfx.checked";
+const PREF_BLOCKLIST_ENFORCE_SIGNING         = "services.blocklist.signing.enforced";
+
+const INVALID_SIGNATURE = "Invalid content/signature";
 
 this.FILENAME_ADDONS_JSON  = "blocklist-addons.json";
 this.FILENAME_GFX_JSON     = "blocklist-gfx.json";
 this.FILENAME_PLUGINS_JSON = "blocklist-plugins.json";
 
+function mergeChanges(localRecords, changes) {
+  // Kinto.js adds attributes to local records that aren't present on server.
+  // (e.g. _status)
+  const stripPrivateProps = (obj) => {
+    return Object.keys(obj).reduce((current, key) => {
+      if (!key.startsWith("_")) {
+        current[key] = obj[key];
+      }
+      return current;
+    }, {});
+  };
+
+  const records = {};
+  // Local records by id.
+  localRecords.forEach((record) => records[record.id] = stripPrivateProps(record));
+  // All existing records are replaced by the version from the server.
+  changes.forEach((record) => records[record.id] = record);
+
+  return Object.values(records)
+    // Filter out deleted records.
+    .filter((record) => record.deleted != true)
+    // Sort list by record id.
+    .sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
+}
+
+
+function fetchCollectionMetadata(collection) {
+  const client = new KintoHttpClient(collection.api.remote);
+  return client.bucket(collection.bucket).collection(collection.name).getMetadata()
+    .then(result => {
+      return result.signature;
+    });
+}
+
+function fetchRemoteCollection(collection) {
+  const client = new KintoHttpClient(collection.api.remote);
+  return client.bucket(collection.bucket)
+           .collection(collection.name)
+           .listRecords({sort: "id"});
+}
 
 /**
  * Helper to instantiate a Kinto client based on preferences for remote server
  * URL and bucket name. It uses the `FirefoxAdapter` which relies on SQLite to
  * persist the local DB.
  */
 function kintoClient() {
   let base = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
@@ -56,47 +102,108 @@ function kintoClient() {
   };
 
   return new Kinto(config);
 }
 
 
 class BlocklistClient {
 
-  constructor(collectionName, lastCheckTimePref, processCallback) {
+  constructor(collectionName, lastCheckTimePref, processCallback, signerName) {
     this.collectionName = collectionName;
     this.lastCheckTimePref = lastCheckTimePref;
     this.processCallback = processCallback;
+    this.signerName = signerName;
+  }
+
+  validateCollectionSignature(payload, collection, ignoreLocal) {
+    return Task.spawn((function* () {
+      // this is a content-signature field from an autograph response.
+      const {x5u, signature} = yield fetchCollectionMetadata(collection);
+      const certChain = yield fetch(x5u).then((res) => res.text());
+
+      const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
+                         .createInstance(Ci.nsIContentSignatureVerifier);
+
+      let records;
+      if (!ignoreLocal) {
+        const localRecords = (yield collection.list()).data;
+        records = mergeChanges(localRecords, payload.changes);
+      } else {
+        records = payload.data;
+      }
+      const serialized = CanonicalJSON.stringify(records);
+
+      if (verifier.verifyContentSignature(serialized, "p384ecdsa=" + signature,
+                                          certChain,
+                                          this.signerName)) {
+        // In case the hash is valid, apply the changes locally.
+        return payload;
+      }
+      throw new Error(INVALID_SIGNATURE);
+    }).bind(this));
   }
 
   /**
    * Synchronize from Kinto server, if necessary.
    *
    * @param {int}  lastModified the lastModified date (on the server) for
                                 the remote collection.
    * @param {Date} serverTime   the current date return by the server.
    * @return {Promise}          which rejects on sync or process failure.
    */
   maybeSync(lastModified, serverTime) {
     let db = kintoClient();
-    let collection = db.collection(this.collectionName);
+    let opts = {};
+    let enforceCollectionSigning =
+      Services.prefs.getBoolPref(PREF_BLOCKLIST_ENFORCE_SIGNING);
+
+    // if there is a signerName and collection signing is enforced, add a
+    // hook for incoming changes that validates the signature
+    if (this.signerName && enforceCollectionSigning) {
+      opts.hooks = {
+        "incoming-changes": [this.validateCollectionSignature.bind(this)]
+      }
+    }
+
+    let collection = db.collection(this.collectionName, opts);
 
     return Task.spawn((function* syncCollection() {
       try {
         yield collection.db.open();
 
         let collectionLastModified = yield collection.db.getLastModified();
         // If the data is up to date, there's no need to sync. We still need
         // to record the fact that a check happened.
         if (lastModified <= collectionLastModified) {
           this.updateLastCheck(serverTime);
           return;
         }
         // Fetch changes from server.
-        yield collection.sync();
+        try {
+          let syncResult = yield collection.sync();
+          if (!syncResult.ok) {
+            throw new Error("Sync failed");
+          }
+        } catch (e) {
+          if (e.message == INVALID_SIGNATURE) {
+            // if sync fails with a signature error, it's likely that our
+            // local data has been modified in some way.
+            // We will attempt to fix this by retrieving the whole
+            // remote collection.
+            let payload = yield fetchRemoteCollection(collection);
+            yield this.validateCollectionSignature(payload, collection, true);
+            // if the signature is good (we haven't thrown), replace the
+            // local data
+            yield collection.clear();
+            yield collection.loadDump(payload.data);
+          } else {
+            throw e;
+          }
+        }
         // Read local collection of records.
         let list = yield collection.list();
 
         yield this.processCallback(list.data);
 
         // Track last update.
         this.updateLastCheck(serverTime);
       } finally {
@@ -158,17 +265,18 @@ function* updateJSONBlocklist(filename, 
     Cu.reportError(e);
   }
 }
 
 
 this.OneCRLBlocklistClient = new BlocklistClient(
   Services.prefs.getCharPref(PREF_BLOCKLIST_ONECRL_COLLECTION),
   PREF_BLOCKLIST_ONECRL_CHECKED_SECONDS,
-  updateCertBlocklist
+  updateCertBlocklist,
+  "onecrl.content-signature.mozilla.org"
 );
 
 this.AddonBlocklistClient = new BlocklistClient(
   Services.prefs.getCharPref(PREF_BLOCKLIST_ADDONS_COLLECTION),
   PREF_BLOCKLIST_ADDONS_CHECKED_SECONDS,
   updateJSONBlocklist.bind(undefined, FILENAME_ADDONS_JSON)
 );
 
--- a/services/common/tests/moz.build
+++ b/services/common/tests/moz.build
@@ -1,7 +1,11 @@
 # -*- Mode: python; c-basic-offset: 4; 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/.
 
 XPCSHELL_TESTS_MANIFESTS += ['unit/xpcshell.ini']
+
+TEST_DIRS += [
+    'unit'
+]
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; c-basic-offset: 4; 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/.
+
+TEST_DIRS += [
+    'test_blocklist_signatures'
+]
--- a/services/common/tests/unit/test_blocklist_certificates.js
+++ b/services/common/tests/unit/test_blocklist_certificates.js
@@ -103,16 +103,20 @@ add_task(function* test_something(){
   // hasn't changed
   Services.prefs.setIntPref("services.blocklist.onecrl.checked", 0);
   yield OneCRLBlocklistClient.maybeSync(3000, Date.now());
   let newValue = Services.prefs.getIntPref("services.blocklist.onecrl.checked");
   do_check_neq(newValue, 0);
 });
 
 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);
+
   // Set up an HTTP Server
   server = new HttpServer();
   server.start(-1);
 
   run_next_test();
 
   do_register_cleanup(function() {
     server.stop(() => { });
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_blocklist_signatures.js
@@ -0,0 +1,482 @@
+"use strict";
+
+Cu.import("resource://services-common/blocklist-updater.js");
+Cu.import("resource://testing-common/httpd.js");
+
+const { loadKinto } = Cu.import("resource://services-common/kinto-offline-client.js");
+const { NetUtil } = Cu.import("resource://gre/modules/NetUtil.jsm", {});
+const { OneCRLBlocklistClient } = Cu.import("resource://services-common/blocklist-clients.js");
+
+let server;
+
+const PREF_BLOCKLIST_BUCKET            = "services.blocklist.bucket";
+const PREF_BLOCKLIST_ENFORCE_SIGNING   = "services.blocklist.signing.enforced";
+const PREF_BLOCKLIST_ONECRL_COLLECTION = "services.blocklist.onecrl.collection";
+const PREF_SETTINGS_SERVER             = "services.settings.server";
+const PREF_SIGNATURE_ROOT              = "security.content.signature.root_hash";
+
+
+const CERT_DIR = "test_blocklist_signatures/";
+const CHAIN_FILES =
+    ["collection_signing_ee.pem",
+     "collection_signing_int.pem",
+     "collection_signing_root.pem"];
+
+function getFileData(file) {
+  const stream = Cc["@mozilla.org/network/file-input-stream;1"]
+                   .createInstance(Ci.nsIFileInputStream);
+  stream.init(file, -1, 0, 0);
+  const data = NetUtil.readInputStreamToString(stream, stream.available());
+  stream.close();
+  return data;
+}
+
+function setRoot() {
+  const filename = CERT_DIR + CHAIN_FILES[0];
+
+  const certFile = do_get_file(filename, false);
+  const b64cert = getFileData(certFile)
+                    .replace(/-----BEGIN CERTIFICATE-----/, "")
+                    .replace(/-----END CERTIFICATE-----/, "")
+                    .replace(/[\r\n]/g, "");
+  const certdb = Cc["@mozilla.org/security/x509certdb;1"]
+                   .getService(Ci.nsIX509CertDB);
+  const cert = certdb.constructX509FromBase64(b64cert);
+  Services.prefs.setCharPref(PREF_SIGNATURE_ROOT, cert.sha256Fingerprint);
+}
+
+function getCertChain() {
+  const chain = [];
+  for (let file of CHAIN_FILES) {
+    chain.push(getFileData(do_get_file(CERT_DIR + file)));
+  }
+  return chain.join("\n");
+}
+
+// Check to ensure maybeSync is called with correct values when a changes
+// document contains information on when a collection was last modified
+add_task(function* test_check_signatures(){
+  const port = server.identity.primaryPort;
+
+  // a response to give the client when the cert chain is expected
+  function makeMetaResponseBody(lastModified, signature) {
+    return {
+      data: {
+        id: "certificates",
+        last_modified: lastModified,
+        signature: {
+          x5u: `http://localhost:${port}/test_blocklist_signatures/test_cert_chain.pem`,
+          public_key: "fake",
+          "content-signature": `x5u=http://localhost:${port}/test_blocklist_signatures/test_cert_chain.pem;p384ecdsa=${signature}`,
+          signature_encoding: "rs_base64url",
+          signature: signature,
+          hash_algorithm: "sha384",
+          ref: "1yryrnmzou5rf31ou80znpnq8n"
+        }
+      }
+    };
+  }
+
+  function makeMetaResponse(eTag, body, comment) {
+    return {
+      comment: comment,
+      sampleHeaders: [
+        "Content-Type: application/json; charset=UTF-8",
+        `ETag: \"${eTag}\"`
+      ],
+      status: {status: 200, statusText: "OK"},
+      responseBody: JSON.stringify(body)
+    };
+  }
+
+  function registerHandlers(responses){
+    function handleResponse (serverTimeMillis, request, response) {
+      const key = `${request.method}:${request.path}?${request.queryString}`;
+      const available = responses[key];
+      const sampled = available.length > 1 ? available.shift() : available[0];
+
+      if (!sampled) {
+        do_throw(`unexpected ${request.method} request for ${request.path}?${request.queryString}`);
+      }
+
+      response.setStatusLine(null, sampled.status.status,
+                            sampled.status.statusText);
+      // send the headers
+      for (let headerLine of sampled.sampleHeaders) {
+        let headerElements = headerLine.split(':');
+        response.setHeader(headerElements[0], headerElements[1].trimLeft());
+      }
+
+      // set the server date
+      response.setHeader("Date", (new Date(serverTimeMillis)).toUTCString());
+
+      response.write(sampled.responseBody);
+    }
+
+    for (let key of Object.keys(responses)) {
+      const keyParts = key.split(":");
+      const method = keyParts[0];
+      const valueParts = keyParts[1].split("?");
+      const path = valueParts[0];
+
+      server.registerPathHandler(path, handleResponse.bind(null, 2000));
+    }
+  }
+
+  // First, perform a signature verification with known data and signature
+  // to ensure things are working correctly
+  let verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
+                   .createInstance(Ci.nsIContentSignatureVerifier);
+
+  const emptyData = '[]';
+  const emptySignature = "p384ecdsa=zbugm2FDitsHwk5-IWsas1PpWwY29f0Fg5ZHeqD8fzep7AVl2vfcaHA7LdmCZ28qZLOioGKvco3qT117Q4-HlqFTJM7COHzxGyU2MMJ0ZTnhJrPOC1fP3cVQjU1PTWi9";
+  const name = "onecrl.content-signature.mozilla.org";
+  ok(verifier.verifyContentSignature(emptyData, emptySignature,
+                                     getCertChain(), name));
+
+  verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
+               .createInstance(Ci.nsIContentSignatureVerifier);
+
+  const collectionData = '[{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:43:37Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"97fbf7c4-3ef2-f54f-0029-1ba6540c63ea","issuerName":"MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==","last_modified":2000,"serialNumber":"BAAAAAABA/A35EU="},{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:48:11Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc","issuerName":"MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB","last_modified":3000,"serialNumber":"BAAAAAABI54PryQ="}]';
+  const collectionSignature = "p384ecdsa=f4pA2tYM5jQgWY6YUmhUwQiBLj6QO5sHLD_5MqLePz95qv-7cNCuQoZnPQwxoptDtW8hcWH3kLb0quR7SB-r82gkpR9POVofsnWJRA-ETb0BcIz6VvI3pDT49ZLlNg3p";
+
+  ok(verifier.verifyContentSignature(collectionData, collectionSignature, getCertChain(), name));
+
+  // set up prefs so the kinto updater talks to the test server
+  Services.prefs.setCharPref(PREF_SETTINGS_SERVER,
+    `http://localhost:${server.identity.primaryPort}/v1`);
+
+  // Set up some data we need for our test
+  let startTime = Date.now();
+
+  // These are records we'll use in the test collections
+  const RECORD1 = {
+    details: {
+      bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
+      created: "2016-01-18T14:43:37Z",
+      name: "GlobalSign certs",
+      who: ".",
+      why: "."
+    },
+    enabled: true,
+    id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea",
+    issuerName: "MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==",
+    last_modified: 2000,
+    serialNumber: "BAAAAAABA/A35EU="
+  };
+
+  const RECORD2 = {
+    details: {
+      bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
+      created: "2016-01-18T14:48:11Z",
+      name: "GlobalSign certs",
+      who: ".",
+      why: "."
+    },
+    enabled: true,
+    id: "e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc",
+    issuerName: "MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB",
+    last_modified: 3000,
+    serialNumber: "BAAAAAABI54PryQ="
+  };
+
+  const RECORD3 = {
+    details: {
+      bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
+      created: "2016-01-18T14:48:11Z",
+      name: "GlobalSign certs",
+      who: ".",
+      why: "."
+    },
+    enabled: true,
+    id: "c7c49b69-a4ab-418e-92a9-e1961459aa7f",
+    issuerName: "MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB",
+    last_modified: 4000,
+    serialNumber: "BAAAAAABI54PryQ="
+  };
+
+  const RECORD1_DELETION = {
+    deleted: true,
+    enabled: true,
+    id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea",
+    last_modified: 3500,
+  };
+
+  // Check that a signature on an empty collection is OK
+  // We need to set up paths on the HTTP server to return specific data from
+  // specific paths for each test. Here we prepare data for each response.
+
+  // A cert chain response (this the cert chain that contains the signing
+  // cert, the root and any intermediates in between). This is used in each
+  // sync.
+  const RESPONSE_CERT_CHAIN = {
+    comment: "RESPONSE_CERT_CHAIN",
+    sampleHeaders: [
+      "Content-Type: text/plain; charset=UTF-8"
+    ],
+    status: {status: 200, statusText: "OK"},
+    responseBody: getCertChain()
+  };
+
+  // A server settings response. This is used in each sync.
+  const RESPONSE_SERVER_SETTINGS = {
+    comment: "RESPONSE_SERVER_SETTINGS",
+    sampleHeaders: [
+      "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"})
+  };
+
+  // This is the initial, empty state of the collection. This is only used
+  // for the first sync.
+  const RESPONSE_EMPTY_INITIAL = {
+    comment: "RESPONSE_EMPTY_INITIAL",
+    sampleHeaders: [
+      "Content-Type: application/json; charset=UTF-8",
+      "ETag: \"1000\""
+    ],
+    status: {status: 200, statusText: "OK"},
+    responseBody: JSON.stringify({"data": []})
+  };
+
+  const RESPONSE_BODY_META_EMPTY_SIG = makeMetaResponseBody(1000,
+    "lJj7PfrLvvLcDBPBWQrV10rY5s1OlUAITx9UT-K_wzxmgEgS7vy8LzJQh5-rdpXHfZW5lKM5itpYwyscV9LkJSuVaozITP81_5zg8Pw6OifmqHcvBE81AtRv0r_eBVd0");
+
+  // The collection metadata containing the signature for the empty
+  // collection.
+  const RESPONSE_META_EMPTY_SIG =
+    makeMetaResponse(1000, RESPONSE_BODY_META_EMPTY_SIG,
+                     "RESPONSE_META_EMPTY_SIG");
+
+  // Here, we map request method and path to the available responses
+  const emptyCollectionResponses = {
+    "GET:/test_blocklist_signatures/test_cert_chain.pem?":[RESPONSE_CERT_CHAIN],
+    "GET:/v1/?": [RESPONSE_SERVER_SETTINGS],
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=-last_modified":
+      [RESPONSE_EMPTY_INITIAL],
+    "GET:/v1/buckets/blocklists/collections/certificates?":
+      [RESPONSE_META_EMPTY_SIG]
+  };
+
+  // .. and use this map to register handlers for each path
+  registerHandlers(emptyCollectionResponses);
+
+  // With all of this set up, we attempt a sync. This will resolve if all is
+  // well and throw if something goes wrong.
+  yield OneCRLBlocklistClient.maybeSync(1000, startTime);
+
+  // Check that some additions (2 records) to the collection have a valid
+  // signature.
+
+  // This response adds two entries (RECORD1 and RECORD2) to the collection
+  const RESPONSE_TWO_ADDED = {
+    comment: "RESPONSE_TWO_ADDED",
+    sampleHeaders: [
+        "Content-Type: application/json; charset=UTF-8",
+        "ETag: \"3000\""
+    ],
+    status: {status: 200, statusText: "OK"},
+    responseBody: JSON.stringify({"data": [RECORD2, RECORD1]})
+  };
+
+  const RESPONSE_BODY_META_TWO_ITEMS_SIG = makeMetaResponseBody(3000,
+    "f4pA2tYM5jQgWY6YUmhUwQiBLj6QO5sHLD_5MqLePz95qv-7cNCuQoZnPQwxoptDtW8hcWH3kLb0quR7SB-r82gkpR9POVofsnWJRA-ETb0BcIz6VvI3pDT49ZLlNg3p");
+
+  // A signature response for the collection containg RECORD1 and RECORD2
+  const RESPONSE_META_TWO_ITEMS_SIG =
+    makeMetaResponse(3000, RESPONSE_BODY_META_TWO_ITEMS_SIG,
+                     "RESPONSE_META_TWO_ITEMS_SIG");
+
+  const twoItemsResponses = {
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=-last_modified&_since=1000":
+      [RESPONSE_TWO_ADDED],
+    "GET:/v1/buckets/blocklists/collections/certificates?":
+      [RESPONSE_META_TWO_ITEMS_SIG]
+  };
+  registerHandlers(twoItemsResponses);
+  yield OneCRLBlocklistClient.maybeSync(3000, startTime);
+
+  // Check the collection with one addition and one removal has a valid
+  // signature
+
+  // Remove RECORD1, add RECORD3
+  const RESPONSE_ONE_ADDED_ONE_REMOVED = {
+    comment: "RESPONSE_ONE_ADDED_ONE_REMOVED ",
+    sampleHeaders: [
+      "Content-Type: application/json; charset=UTF-8",
+      "ETag: \"4000\""
+    ],
+    status: {status: 200, statusText: "OK"},
+    responseBody: JSON.stringify({"data": [RECORD3, RECORD1_DELETION]})
+  };
+
+  const RESPONSE_BODY_META_THREE_ITEMS_SIG = makeMetaResponseBody(4000,
+    "wxVc0AvHZZ0fyZR8tZVtZRBrsVNYIBxOjaKZXgnjyJqfwnyENSZkJLQlm3mho-J_QAxDTp7QPXXVSA-r1SrE3rlqV4BkqE9NTGREKvl5BJzaDEOtxH7VF5WMw49k8q0O");
+
+  // signature response for the collection containing RECORD2 and RECORD3
+  const RESPONSE_META_THREE_ITEMS_SIG =
+    makeMetaResponse(4000, RESPONSE_BODY_META_THREE_ITEMS_SIG,
+                     "RESPONSE_META_THREE_ITEMS_SIG");
+
+  const oneAddedOneRemovedResponses = {
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=-last_modified&_since=3000":
+      [RESPONSE_ONE_ADDED_ONE_REMOVED],
+    "GET:/v1/buckets/blocklists/collections/certificates?":
+      [RESPONSE_META_THREE_ITEMS_SIG]
+  };
+  registerHandlers(oneAddedOneRemovedResponses);
+  yield OneCRLBlocklistClient.maybeSync(4000, startTime);
+
+  // Check the signature is still valid with no operation (no changes)
+
+  // Leave the collection unchanged
+  const RESPONSE_EMPTY_NO_UPDATE = {
+    comment: "RESPONSE_EMPTY_NO_UPDATE ",
+    sampleHeaders: [
+      "Content-Type: application/json; charset=UTF-8",
+      "ETag: \"4000\""
+    ],
+    status: {status: 200, statusText: "OK"},
+    responseBody: JSON.stringify({"data": []})
+  };
+
+  const noOpResponses = {
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=-last_modified&_since=4000":
+      [RESPONSE_EMPTY_NO_UPDATE],
+    "GET:/v1/buckets/blocklists/collections/certificates?":
+      [RESPONSE_META_THREE_ITEMS_SIG]
+  };
+  registerHandlers(noOpResponses);
+  yield OneCRLBlocklistClient.maybeSync(4100, startTime);
+
+  // Check the collection is reset when the signature is invalid
+
+  // Prepare a (deliberately) bad signature to check the collection state is
+  // reset if something is inconsistent
+  const RESPONSE_COMPLETE_INITIAL = {
+    comment: "RESPONSE_COMPLETE_INITIAL ",
+    sampleHeaders: [
+      "Content-Type: application/json; charset=UTF-8",
+      "ETag: \"4000\""
+    ],
+    status: {status: 200, statusText: "OK"},
+    responseBody: JSON.stringify({"data": [RECORD2, RECORD3]})
+  };
+
+  const RESPONSE_COMPLETE_INITIAL_SORTED_BY_ID = {
+    comment: "RESPONSE_COMPLETE_INITIAL ",
+    sampleHeaders: [
+      "Content-Type: application/json; charset=UTF-8",
+      "ETag: \"4000\""
+    ],
+    status: {status: 200, statusText: "OK"},
+    responseBody: JSON.stringify({"data": [RECORD3, RECORD2]})
+  };
+
+  const RESPONSE_BODY_META_BAD_SIG = makeMetaResponseBody(4000,
+      "aW52YWxpZCBzaWduYXR1cmUK");
+
+  const RESPONSE_META_BAD_SIG =
+      makeMetaResponse(4000, RESPONSE_BODY_META_BAD_SIG, "RESPONSE_META_BAD_SIG");
+
+  const badSigGoodSigResponses = {
+    // In this test, we deliberately serve a bad signature initially. The
+    // subsequent sitnature returned is a valid one for the three item
+    // collection.
+    "GET:/v1/buckets/blocklists/collections/certificates?":
+      [RESPONSE_META_BAD_SIG, RESPONSE_META_THREE_ITEMS_SIG],
+    // The first collection state is the three item collection (since
+    // there's a sync with no updates) - but, since the signature is wrong,
+    // another request will be made...
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=-last_modified&_since=4000":
+      [RESPONSE_EMPTY_NO_UPDATE],
+    // The next request is for the full collection. This will be checked
+    // against the valid signature - so the sync should succeed.
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=-last_modified":
+      [RESPONSE_COMPLETE_INITIAL],
+    // The next request is for the full collection sorted by id. This will be
+    // checked against the valid signature - so the sync should succeed.
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=id":
+      [RESPONSE_COMPLETE_INITIAL_SORTED_BY_ID]
+  };
+
+  registerHandlers(badSigGoodSigResponses);
+  yield OneCRLBlocklistClient.maybeSync(5000, startTime);
+
+  const allBadSigResponses = {
+    // In this test, we deliberately serve only a bad signature.
+    "GET:/v1/buckets/blocklists/collections/certificates?":
+      [RESPONSE_META_BAD_SIG],
+    // The first collection state is the three item collection (since
+    // there's a sync with no updates) - but, since the signature is wrong,
+    // another request will be made...
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=-last_modified&_since=4000":
+      [RESPONSE_EMPTY_NO_UPDATE],
+    // The next request is for the full collection sorted by id. This will be
+    // checked against the valid signature - so the sync should succeed.
+    "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=id":
+      [RESPONSE_COMPLETE_INITIAL_SORTED_BY_ID]
+  };
+
+  registerHandlers(allBadSigResponses);
+  try {
+    yield OneCRLBlocklistClient.maybeSync(6000, startTime);
+    do_throw("Sync should fail (the signature is intentionally bad)");
+  } catch (e) {
+    // open the collection manually
+    const base = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
+    const bucket = Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET);
+    const collectionName =
+      Services.prefs.getCharPref(PREF_BLOCKLIST_ONECRL_COLLECTION);
+
+    const Kinto = loadKinto();
+
+    const FirefoxAdapter = Kinto.adapters.FirefoxAdapter;
+
+    const config = {
+      remote: base,
+      bucket: bucket,
+      adapter: FirefoxAdapter,
+    };
+
+    const db = new Kinto(config);
+    const collection = db.collection(collectionName);
+
+    yield collection.db.open();
+
+    // Check we have the expected number of records
+    let records = yield collection.list();
+    do_check_eq(2, records.data.length);
+
+    // Close the collection so the test can exit cleanly
+    yield collection.db.close()
+  }
+});
+
+function run_test() {
+  // ensure signatures are enforced
+  Services.prefs.setBoolPref(PREF_BLOCKLIST_ENFORCE_SIGNING, true);
+
+  // get a signature verifier to ensure nsNSSComponent is initialized
+  Cc["@mozilla.org/security/contentsignatureverifier;1"]
+    .createInstance(Ci.nsIContentSignatureVerifier);
+
+  // set the content signing root to our test root
+  setRoot();
+
+  // Set up an HTTP Server
+  server = new HttpServer();
+  server.start(-1);
+
+  run_next_test();
+
+  do_register_cleanup(function() {
+    server.stop(function() { });
+  });
+}
+
+
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_blocklist_signatures/collection_signing_ee.pem.certspec
@@ -0,0 +1,5 @@
+issuer:collection-signer-int-CA
+subject:collection-signer-ee-int-CA
+subjectKey:secp384r1
+extension:extKeyUsage:codeSigning
+extension:subjectAlternativeName:onecrl.content-signature.mozilla.org
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_blocklist_signatures/collection_signing_int.pem.certspec
@@ -0,0 +1,4 @@
+issuer:collection-signer-ca
+subject:collection-signer-int-CA
+extension:basicConstraints:cA,
+extension:extKeyUsage:codeSigning
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_blocklist_signatures/collection_signing_root.pem.certspec
@@ -0,0 +1,4 @@
+issuer:collection-signer-ca
+subject:collection-signer-ca
+extension:basicConstraints:cA,
+extension:extKeyUsage:codeSigning
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_blocklist_signatures/moz.build
@@ -0,0 +1,14 @@
+# -*- Mode: python; c-basic-offset: 4; 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/.
+
+test_certificates = (
+    'collection_signing_root.pem',
+    'collection_signing_int.pem',
+    'collection_signing_ee.pem',
+)
+
+for test_certificate in test_certificates:
+    GeneratedTestCertificate(test_certificate)
--- a/services/common/tests/unit/xpcshell.ini
+++ b/services/common/tests/unit/xpcshell.ini
@@ -1,24 +1,26 @@
 [DEFAULT]
 head = head_global.js head_helpers.js head_http.js
 tail =
 firefox-appdir = browser
 skip-if = toolkit == 'gonk'
 support-files =
   test_storage_adapter/**
+  test_blocklist_signatures/**
 
 # Test load modules first so syntax failures are caught early.
 [test_load_modules.js]
 
 [test_blocklist_certificates.js]
 [test_blocklist_clients.js]
 [test_blocklist_updater.js]
 
 [test_kinto.js]
+[test_blocklist_signatures.js]
 [test_storage_adapter.js]
 
 [test_utils_atob.js]
 [test_utils_convert_string.js]
 [test_utils_dateprefs.js]
 [test_utils_deepCopy.js]
 [test_utils_encodeBase32.js]
 [test_utils_encodeBase64URL.js]