Bug 1263602 - Verify kinto collection signatures using the content signature service. r=MattN, r=leplatrem draft
authorMark Goodwin <mgoodwin@mozilla.com>
Wed, 01 Jun 2016 18:39:57 +0100
changeset 374025 616258d9736a70893a637915e8d1259debac978b
parent 373501 25321494921c824703a605127fb1f99b1faf5910
child 522525 c120b8c1c045feb775d4075a1f93842d06622879
push id19901
push usermgoodwin@mozilla.com
push dateWed, 01 Jun 2016 17:40:39 +0000
reviewersMattN, leplatrem
bugs1263602
milestone49.0a1
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
@@ -2112,16 +2112,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", true);
+
 // 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]