Bug 1397230 - Generalize blocklist clients to remote settings clients r=mgoodwin
authorMathieu Leplatre <mathieu@mozilla.com>
Tue, 13 Mar 2018 16:23:57 +0100
changeset 409567 fc6d9baa6cc9f6cbdb6fa790a2640141e5625601
parent 409566 00c71dbf92749b5853fc1d671cc2354f116c5d0b
child 409568 c91f7e66f3424890f13fe80e44a34df44ecccfa6
push id33692
push usernbeleuzu@mozilla.com
push dateFri, 23 Mar 2018 09:49:37 +0000
treeherdermozilla-central@9b72102a99b3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmgoodwin
bugs1397230
milestone61.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 1397230 - Generalize blocklist clients to remote settings clients r=mgoodwin MozReview-Commit-ID: 9VAsTFCuZUf
browser/base/content/content.js
browser/base/content/test/about/browser_aboutCertError.js
modules/libpref/init/all.js
security/manager/ssl/tests/unit/test_cert_blocklist.js
services/common/blocklist-clients.js
services/common/blocklist-updater.js
services/common/kinto-storage-adapter.js
services/common/moz.build
services/common/remote-settings.js
services/common/tests/unit/test_blocklist_certificates.js
services/common/tests/unit/test_blocklist_clients.js
services/common/tests/unit/test_blocklist_pinning.js
services/common/tests/unit/test_blocklist_signatures.js
services/common/tests/unit/test_blocklist_updater.js
services/common/tests/unit/test_remote_settings_poll.js
services/common/tests/unit/xpcshell.ini
toolkit/mozapps/extensions/nsBlocklistService.js
--- a/browser/base/content/content.js
+++ b/browser/base/content/content.js
@@ -71,18 +71,18 @@ const MOZILLA_PKIX_ERROR_BASE = Ci.nsINS
 const SEC_ERROR_EXPIRED_CERTIFICATE                = SEC_ERROR_BASE + 11;
 const SEC_ERROR_UNKNOWN_ISSUER                     = SEC_ERROR_BASE + 13;
 const SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE         = SEC_ERROR_BASE + 30;
 const SEC_ERROR_OCSP_FUTURE_RESPONSE               = SEC_ERROR_BASE + 131;
 const SEC_ERROR_OCSP_OLD_RESPONSE                  = SEC_ERROR_BASE + 132;
 const MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE = MOZILLA_PKIX_ERROR_BASE + 5;
 const MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE = MOZILLA_PKIX_ERROR_BASE + 6;
 
-const PREF_BLOCKLIST_CLOCK_SKEW_SECONDS = "services.blocklist.clock_skew_seconds";
-const PREF_BLOCKLIST_LAST_FETCHED = "services.blocklist.last_update_seconds";
+const PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS = "services.settings.clock_skew_seconds";
+const PREF_SERVICES_SETTINGS_LAST_FETCHED       = "services.settings.last_update_seconds";
 
 const PREF_SSL_IMPACT_ROOTS = ["security.tls.version.", "security.ssl3."];
 
 
 function getSerializedSecurityInfo(docShell) {
   let serhelper = Cc["@mozilla.org/network/serialization-helper;1"]
                     .getService(Ci.nsISerializationHelper);
 
@@ -289,28 +289,29 @@ var AboutNetAndCertErrorListener = {
     let baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
 
     switch (msg.data.code) {
       case SEC_ERROR_UNKNOWN_ISSUER:
         learnMoreLink.href = baseURL + "security-error";
         break;
 
       // In case the certificate expired we make sure the system clock
-      // matches the blocklist ping (Kinto) time and is not before the build date.
+      // matches the remote-settings service (blocklist via Kinto) ping time
+      // and is not before the build date.
       case SEC_ERROR_EXPIRED_CERTIFICATE:
       case SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE:
       case SEC_ERROR_OCSP_FUTURE_RESPONSE:
       case SEC_ERROR_OCSP_OLD_RESPONSE:
       case MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE:
       case MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE:
 
-        // We check against Kinto time first if available, because that allows us
+        // We check against the remote-settings server time first if available, because that allows us
         // to give the user an approximation of what the correct time is.
-        let difference = Services.prefs.getIntPref(PREF_BLOCKLIST_CLOCK_SKEW_SECONDS, 0);
-        let lastFetched = Services.prefs.getIntPref(PREF_BLOCKLIST_LAST_FETCHED, 0) * 1000;
+        let difference = Services.prefs.getIntPref(PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS, 0);
+        let lastFetched = Services.prefs.getIntPref(PREF_SERVICES_SETTINGS_LAST_FETCHED, 0) * 1000;
 
         let now = Date.now();
         let certRange = this._getCertValidityRange(docShell);
 
         let approximateDate = now - difference * 1000;
         // If the difference is more than a day, we last fetched the date in the last 5 days,
         // and adjusting the date per the interval would make the cert valid, warn the user:
         if (Math.abs(difference) > 60 * 60 * 24 && (now - lastFetched) <= 60 * 60 * 24 * 5 &&
--- a/browser/base/content/test/about/browser_aboutCertError.js
+++ b/browser/base/content/test/about/browser_aboutCertError.js
@@ -160,17 +160,17 @@ add_task(async function checkAppBuildIDI
   let month = parseInt(appBuildID.substr(4, 2), 10);
   let day = parseInt(appBuildID.substr(6, 2), 10);
 
   ok(year >= 2016 && year <= 2100, "appBuildID contains a valid year");
   ok(month >= 1 && month <= 12, "appBuildID contains a valid month");
   ok(day >= 1 && day <= 31, "appBuildID contains a valid day");
 });
 
-const PREF_BLOCKLIST_CLOCK_SKEW_SECONDS = "services.blocklist.clock_skew_seconds";
+const PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS = "services.settings.clock_skew_seconds";
 
 add_task(async function checkWrongSystemTimeWarning() {
   async function setUpPage() {
     let browser;
     let certErrorLoaded;
     await BrowserTestUtils.openNewForegroundTab(gBrowser, () => {
       gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, BAD_CERT);
       browser = gBrowser.selectedBrowser;
@@ -202,17 +202,17 @@ add_task(async function checkWrongSystem
   });
 
   // pretend we have a positively skewed (ahead) system time
   let serverDate = new Date("2015/10/27");
   let serverDateFmt = formatter.format(serverDate);
   let localDateFmt = formatter.format(new Date());
 
   let skew = Math.floor((Date.now() - serverDate.getTime()) / 1000);
-  await SpecialPowers.pushPrefEnv({set: [[PREF_BLOCKLIST_CLOCK_SKEW_SECONDS, skew]]});
+  await SpecialPowers.pushPrefEnv({set: [[PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS, skew]]});
 
   info("Loading a bad cert page with a skewed clock");
   let message = await setUpPage();
 
   isnot(message.divDisplay, "none", "Wrong time message information is visible");
   ok(message.text.includes("clock appears to show the wrong time"),
      "Correct error message found");
   ok(message.text.includes("expired.example.com"), "URL found in error message");
@@ -223,44 +223,44 @@ add_task(async function checkWrongSystem
   BrowserTestUtils.removeTab(gBrowser.selectedTab);
 
   // pretend we have a negatively skewed (behind) system time
   serverDate = new Date();
   serverDate.setYear(serverDate.getFullYear() + 1);
   serverDateFmt = formatter.format(serverDate);
 
   skew = Math.floor((Date.now() - serverDate.getTime()) / 1000);
-  await SpecialPowers.pushPrefEnv({set: [[PREF_BLOCKLIST_CLOCK_SKEW_SECONDS, skew]]});
+  await SpecialPowers.pushPrefEnv({set: [[PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS, skew]]});
 
   info("Loading a bad cert page with a skewed clock");
   message = await setUpPage();
 
   isnot(message.divDisplay, "none", "Wrong time message information is visible");
   ok(message.text.includes("clock appears to show the wrong time"),
      "Correct error message found");
   ok(message.text.includes("expired.example.com"), "URL found in error message");
   ok(message.systemDate.includes(localDateFmt), "correct local date displayed");
   ok(message.actualDate.includes(serverDateFmt), "correct server date displayed");
 
   BrowserTestUtils.removeTab(gBrowser.selectedTab);
 
   // pretend we only have a slightly skewed system time, four hours
   skew = 60 * 60 * 4;
-  await SpecialPowers.pushPrefEnv({set: [[PREF_BLOCKLIST_CLOCK_SKEW_SECONDS, skew]]});
+  await SpecialPowers.pushPrefEnv({set: [[PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS, skew]]});
 
   info("Loading a bad cert page with an only slightly skewed clock");
   message = await setUpPage();
 
   is(message.divDisplay, "none", "Wrong time message information is not visible");
 
   BrowserTestUtils.removeTab(gBrowser.selectedTab);
 
   // now pretend we have no skewed system time
   skew = 0;
-  await SpecialPowers.pushPrefEnv({set: [[PREF_BLOCKLIST_CLOCK_SKEW_SECONDS, skew]]});
+  await SpecialPowers.pushPrefEnv({set: [[PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS, skew]]});
 
   info("Loading a bad cert page with no skewed clock");
   message = await setUpPage();
 
   is(message.divDisplay, "none", "Wrong time message information is not visible");
 
   BrowserTestUtils.removeTab(gBrowser.selectedTab);
 }).skip(); // Skipping because of bug 1414804.
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -2697,55 +2697,49 @@ pref("security.cert_pinning.process_head
 pref("security.view-source.reachable-from-inner-protocol", false);
 
 // If set to true, in some limited circumstances it may be possible to load
 // privileged content in frames inside unprivileged content.
 pref("security.allow_chrome_frames_inside_content", false);
 
 // Services security settings
 pref("services.settings.server", "https://firefox.settings.services.mozilla.com/v1");
+pref("services.settings.changes.path", "/buckets/monitor/collections/changes/records");
 
 // Blocklist preferences
 pref("extensions.blocklist.enabled", true);
 // OneCRL freshness checking depends on this value, so if you change it,
 // please also update security.onecrl.maximum_staleness_in_seconds.
 pref("extensions.blocklist.interval", 86400);
 // Required blocklist freshness for OneCRL OCSP bypass
 // (default is 1.25x extensions.blocklist.interval, or 30 hours)
 pref("security.onecrl.maximum_staleness_in_seconds", 108000);
 pref("extensions.blocklist.url", "https://blocklists.settings.services.mozilla.com/v1/blocklist/3/%APP_ID%/%APP_VERSION%/%PRODUCT%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/%PING_COUNT%/%TOTAL_PING_COUNT%/%DAYS_SINCE_LAST_PING%/");
 pref("extensions.blocklist.detailsURL", "https://blocked.cdn.mozilla.net/");
 pref("extensions.blocklist.itemURL", "https://blocked.cdn.mozilla.net/%blockID%.html");
 // Controls what level the blocklist switches from warning about items to forcibly
 // blocking them.
 pref("extensions.blocklist.level", 2);
 // Blocklist via settings server (Kinto)
-pref("services.blocklist.changes.path", "/buckets/monitor/collections/changes/records");
 pref("services.blocklist.bucket", "blocklists");
 pref("services.blocklist.onecrl.collection", "certificates");
 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.pinning.enabled", true);
 pref("services.blocklist.pinning.bucket", "pinning");
 pref("services.blocklist.pinning.collection", "pins");
 pref("services.blocklist.pinning.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);
-
 // Enable blocklists via the services settings mechanism
 pref("services.blocklist.update_enabled", true);
 
-
 // Modifier key prefs: default to Windows settings,
 // menu access key = alt, accelerator key = control.
 // Use 17 for Ctrl, 18 for Alt, 224 for Meta, 91 for Win, 0 for none. Mac settings in macprefs.js
 pref("ui.key.accelKey", 17);
 pref("ui.key.menuAccessKey", 18);
 pref("ui.key.generalAccessKey", -1);
 
 // If generalAccessKey is -1, use the following two prefs instead.
--- a/security/manager/ssl/tests/unit/test_cert_blocklist.js
+++ b/security/manager/ssl/tests/unit/test_cert_blocklist.js
@@ -198,35 +198,34 @@ function test_is_revoked(certList, issue
                                 serialString ? serialString.length : 0,
                                 subject,
                                 subjectString ? subjectString.length : 0,
                                 pubKey,
                                 pubKeyString ? pubKeyString.length : 0);
 }
 
 function fetch_blocklist() {
-  Services.prefs.setBoolPref("services.blocklist.load_dump", false);
-  Services.prefs.setBoolPref("services.blocklist.signing.enforced", false);
+  Services.prefs.setBoolPref("services.settings.load_dump", false);
+  Services.prefs.setBoolPref("services.settings.verify_signature", false);
   Services.prefs.setCharPref("services.settings.server",
                              `http://localhost:${port}/v1`);
   Services.prefs.setCharPref("extensions.blocklist.url",
                               `http://localhost:${port}/blocklist.xml`);
   let blocklist = Cc["@mozilla.org/extensions/blocklist;1"]
                     .getService(Ci.nsITimerCallback);
 
   return new Promise((resolve) => {
-    let certblockObserver = {
+    const e = "remote-settings-changes-polled";
+    const changesPolledObserver = {
       observe(aSubject, aTopic, aData) {
-        Services.obs.removeObserver(this, "blocklist-updater-versions-checked");
+        Services.obs.removeObserver(this, e);
         resolve();
       }
     };
-
-    Services.obs.addObserver(certblockObserver, "blocklist-updater-versions-checked");
-
+    Services.obs.addObserver(changesPolledObserver, e);
     blocklist.notify(null);
   });
 }
 
 function* generate_revocations_txt_lines() {
   let profile = do_get_profile();
   let revocations = profile.clone();
   revocations.append("revocations.txt");
@@ -348,19 +347,17 @@ function run_test() {
     // Check the cert validates before we load the blocklist
     file = "test_onecrl/same-issuer-ee.pem";
     verify_cert(file, PRErrorCodeSuccess);
 
     run_next_test();
   });
 
   // blocklist load is async so we must use add_test from here
-  add_task(function () {
-    return fetch_blocklist();
-  });
+  add_task(fetch_blocklist);
 
   add_test(function() {
     // The blocklist will be loaded now. Let's check the data is sane.
     // In particular, we should still have the revoked issuer / serial pair
     // that was in both revocations.txt and the blocklist.
     ok(test_is_revoked(certList, "another imaginary issuer", "serial2."),
       "issuer / serial pair should be blocked");
 
--- a/services/common/blocklist-clients.js
+++ b/services/common/blocklist-clients.js
@@ -1,358 +1,48 @@
 /* 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";
 
-var EXPORTED_SYMBOLS = ["AddonBlocklistClient",
-                        "GfxBlocklistClient",
-                        "OneCRLBlocklistClient",
-                        "PinningBlocklistClient",
-                        "PluginBlocklistClient"];
+var EXPORTED_SYMBOLS = [
+  "initialize",
+  "AddonBlocklistClient",
+  "PluginBlocklistClient",
+  "GfxBlocklistClient",
+  "PinningBlocklistClient",
+];
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
-ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm", {});
-Cu.importGlobalProperties(["fetch"]);
 
-ChromeUtils.defineModuleGetter(this, "FileUtils",
-                               "resource://gre/modules/FileUtils.jsm");
-ChromeUtils.defineModuleGetter(this, "Kinto",
-                               "resource://services-common/kinto-offline-client.js");
-ChromeUtils.defineModuleGetter(this, "KintoHttpClient",
-                               "resource://services-common/kinto-http-client.js");
-ChromeUtils.defineModuleGetter(this, "FirefoxAdapter",
-                               "resource://services-common/kinto-storage-adapter.js");
-ChromeUtils.defineModuleGetter(this, "CanonicalJSON",
-                               "resource://gre/modules/CanonicalJSON.jsm");
-ChromeUtils.defineModuleGetter(this, "UptakeTelemetry",
-                               "resource://services-common/uptake-telemetry.js");
+ChromeUtils.defineModuleGetter(this, "RemoteSettings",
+                               "resource://services-common/remote-settings.js");
 
-const KEY_APPDIR                             = "XCurProcD";
-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_PINNING_ENABLED         = "services.blocklist.pinning.enabled";
 const PREF_BLOCKLIST_PINNING_BUCKET          = "services.blocklist.pinning.bucket";
 const PREF_BLOCKLIST_PINNING_COLLECTION      = "services.blocklist.pinning.collection";
 const PREF_BLOCKLIST_PINNING_CHECKED_SECONDS = "services.blocklist.pinning.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 was the default path in earlier versions of
-// FirefoxAdapter, so for backwards compatibility we maintain this
-// filename, even though it isn't descriptive of who is using it.
-const KINTO_STORAGE_PATH = "kinto.sqlite";
-
-
-
-function mergeChanges(collection, localRecords, changes) {
-  const records = {};
-  // Local records by id.
-  localRecords.forEach((record) => records[record.id] = collection.cleanLocalFields(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)
-    // Sort list by record id.
-    .sort((a, b) => {
-      if (a.id < b.id) {
-        return -1;
-      }
-      return a.id > b.id ? 1 : 0;
-    });
-}
-
-
-function fetchCollectionMetadata(remote, collection) {
-  const client = new KintoHttpClient(remote);
-  return client.bucket(collection.bucket).collection(collection.name).getData()
-    .then(result => {
-      return result.signature;
-    });
-}
-
-function fetchRemoteCollection(remote, collection) {
-  const client = new KintoHttpClient(remote);
-  return client.bucket(collection.bucket)
-           .collection(collection.name)
-           .listRecords({sort: "id"});
-}
-
-
-class BlocklistClient {
-
-  constructor(collectionName, lastCheckTimePref, processCallback, bucketName, signerName) {
-    this.collectionName = collectionName;
-    this.lastCheckTimePref = lastCheckTimePref;
-    this.processCallback = processCallback;
-    this.bucketName = bucketName;
-    this.signerName = signerName;
-
-    this._kinto = null;
-  }
-
-  get identifier() {
-    return `${this.bucketName}/${this.collectionName}`;
-  }
-
-  get filename() {
-    // Replace slash by OS specific path separator (eg. Windows)
-    const identifier = OS.Path.join(...this.identifier.split("/"));
-    return `${identifier}.json`;
-  }
-
-  /**
-   * Open the underlying Kinto collection, using the appropriate adapter and
-   * options. This acts as a context manager where the connection is closed
-   * once the specified `callback` has finished.
-   *
-   * @param {callback} function           the async function to execute with the open SQlite connection.
-   * @param {Object}   options            additional advanced options.
-   * @param {string}   options.bucket     override bucket name of client (default: this.bucketName)
-   * @param {string}   options.collection override collection name of client (default: this.collectionName)
-   * @param {string}   options.path       override default Sqlite path (default: kinto.sqlite)
-   * @param {string}   options.hooks      hooks to execute on synchronization (see Kinto.js docs)
-   */
-  async openCollection(callback, options = {}) {
-    const { bucket = this.bucketName, path = KINTO_STORAGE_PATH } = options;
-    if (!this._kinto) {
-      this._kinto = new Kinto({bucket, adapter: FirefoxAdapter});
-    }
-    let sqliteHandle;
-    try {
-      sqliteHandle = await FirefoxAdapter.openConnection({path});
-      const colOptions = Object.assign({adapterOptions: {sqliteHandle}}, options);
-      const {collection: collectionName = this.collectionName} = options;
-      const collection = this._kinto.collection(collectionName, colOptions);
-      return await callback(collection);
-    } finally {
-      if (sqliteHandle) {
-        await sqliteHandle.close();
-      }
-    }
-  }
-
-  /**
-   * Load the the JSON file distributed with the release for this blocklist.
-   *
-   * For Bug 1257565 this method will have to try to load the file from the profile,
-   * in order to leverage the updateJSONBlocklist() below, which writes a new
-   * dump each time the collection changes.
-   */
-  async loadDumpFile() {
-    // Replace OS specific path separator by / for URI.
-    const { components: folderFile } = OS.Path.split(this.filename);
-    const fileURI = `resource://app/defaults/${folderFile.join("/")}`;
-    const response = await fetch(fileURI);
-    if (!response.ok) {
-      throw new Error(`Could not read from '${fileURI}'`);
-    }
-    // Will be rejected if JSON is invalid.
-    return response.json();
-  }
-
-  async validateCollectionSignature(remote, payload, collection, options = {}) {
-    const {ignoreLocal} = options;
-
-    // this is a content-signature field from an autograph response.
-    const {x5u, signature} = await fetchCollectionMetadata(remote, collection);
-    const certChainResponse = await fetch(x5u);
-    const certChain = await certChainResponse.text();
-
-    const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
-                       .createInstance(Ci.nsIContentSignatureVerifier);
-
-    let toSerialize;
-    if (ignoreLocal) {
-      toSerialize = {
-        last_modified: `${payload.last_modified}`,
-        data: payload.data
-      };
-    } else {
-      const {data: localRecords} = await collection.list();
-      const records = mergeChanges(collection, localRecords, payload.changes);
-      toSerialize = {
-        last_modified: `${payload.lastModified}`,
-        data: records
-      };
-    }
-
-    const serialized = CanonicalJSON.stringify(toSerialize);
-
-    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);
-  }
-
-  /**
-   * 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.
-   * @param {Object} options        additional advanced options.
-   * @param {bool} options.loadDump load initial dump from disk on first sync (default: true)
-   * @return {Promise}              which rejects on sync or process failure.
-   */
-  async maybeSync(lastModified, serverTime, options = {loadDump: true}) {
-    const {loadDump} = options;
-    const remote = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
-    const 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
-    const colOptions = {};
-    if (this.signerName && enforceCollectionSigning) {
-      colOptions.hooks = {
-        "incoming-changes": [(payload, collection) => {
-          return this.validateCollectionSignature(remote, payload, collection);
-        }]
-      };
-    }
-
-    let reportStatus = null;
-    try {
-      return await this.openCollection(async (collection) => {
-        // Synchronize remote data into a local Sqlite DB.
-        let collectionLastModified = await collection.db.getLastModified();
-
-        // If there is no data currently in the collection, attempt to import
-        // initial data from the application defaults.
-        // This allows to avoid synchronizing the whole collection content on
-        // cold start.
-        if (!collectionLastModified && loadDump) {
-          try {
-            const initialData = await this.loadDumpFile();
-            await collection.loadDump(initialData.data);
-            collectionLastModified = await collection.db.getLastModified();
-          } catch (e) {
-            // Report but go-on.
-            Cu.reportError(e);
-          }
-        }
-
-        // 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);
-          reportStatus = UptakeTelemetry.STATUS.UP_TO_DATE;
-          return;
-        }
-
-        // Fetch changes from server.
-        try {
-          // Server changes have priority during synchronization.
-          const strategy = Kinto.syncStrategy.SERVER_WINS;
-          const {ok} = await collection.sync({remote, strategy});
-          if (!ok) {
-            // Some synchronization conflicts occured.
-            reportStatus = UptakeTelemetry.STATUS.CONFLICT_ERROR;
-            throw new Error("Sync failed");
-          }
-        } catch (e) {
-          if (e.message == INVALID_SIGNATURE) {
-            // Signature verification failed during synchronzation.
-            reportStatus = UptakeTelemetry.STATUS.SIGNATURE_ERROR;
-            // 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.
-            const payload = await fetchRemoteCollection(remote, collection);
-            try {
-              await this.validateCollectionSignature(remote, payload, collection, {ignoreLocal: true});
-            } catch (e) {
-              reportStatus = UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR;
-              throw e;
-            }
-            // if the signature is good (we haven't thrown), and the remote
-            // last_modified is newer than the local last_modified, replace the
-            // local data
-            const localLastModified = await collection.db.getLastModified();
-            if (payload.last_modified >= localLastModified) {
-              await collection.clear();
-              await collection.loadDump(payload.data);
-            }
-          } else {
-            // The sync has thrown, it can be a network or a general error.
-            if (/NetworkError/.test(e.message)) {
-              reportStatus = UptakeTelemetry.STATUS.NETWORK_ERROR;
-            } else if (/Backoff/.test(e.message)) {
-              reportStatus = UptakeTelemetry.STATUS.BACKOFF;
-            } else {
-              reportStatus = UptakeTelemetry.STATUS.SYNC_ERROR;
-            }
-            throw e;
-          }
-        }
-        // Read local collection of records.
-        const {data} = await collection.list();
-
-        // Handle the obtained records (ie. apply locally).
-        try {
-          await this.processCallback(data);
-        } catch (e) {
-          reportStatus = UptakeTelemetry.STATUS.APPLY_ERROR;
-          throw e;
-        }
-
-        // Track last update.
-        this.updateLastCheck(serverTime);
-
-      }, colOptions);
-    } catch (e) {
-      // No specific error was tracked, mark it as unknown.
-      if (reportStatus === null) {
-        reportStatus = UptakeTelemetry.STATUS.UNKNOWN_ERROR;
-      }
-      throw e;
-    } finally {
-      // No error was reported, this is a success!
-      if (reportStatus === null) {
-        reportStatus = UptakeTelemetry.STATUS.SUCCESS;
-      }
-      // Report success/error status to Telemetry.
-      UptakeTelemetry.report(this.identifier, reportStatus);
-    }
-  }
-
-  /**
-   * Save last time server was checked in users prefs.
-   *
-   * @param {Date} serverTime   the current date return by server.
-   */
-  updateLastCheck(serverTime) {
-    const checkedServerTimeInSeconds = Math.round(serverTime / 1000);
-    Services.prefs.setIntPref(this.lastCheckTimePref, checkedServerTimeInSeconds);
-  }
-}
 
 /**
  * Revoke the appropriate certificates based on the records from the blocklist.
  *
- * @param {Object} records   current records in the local db.
+ * @param {Object} data   Current records in the local db.
  */
-async function updateCertBlocklist(records) {
+async function updateCertBlocklist({data: records}) {
   const certList = Cc["@mozilla.org/security/certblocklist;1"]
                      .getService(Ci.nsICertBlocklist);
   for (let item of records) {
     try {
       if (item.issuerName && item.serialNumber) {
         certList.revokeCertByIssuerAndSerial(item.issuerName,
                                             item.serialNumber);
       } else if (item.subject && item.pubKeyHash) {
@@ -368,19 +58,19 @@ async function updateCertBlocklist(recor
   }
   certList.saveEntries();
 }
 
 /**
  * Modify the appropriate security pins based on records from the remote
  * collection.
  *
- * @param {Object} records   current records in the local db.
+ * @param {Object} data   Current records in the local db.
  */
-async function updatePinningList(records) {
+async function updatePinningList({data: records}) {
   if (!Services.prefs.getBoolPref(PREF_BLOCKLIST_PINNING_ENABLED)) {
     return;
   }
 
   const siteSecurityService = Cc["@mozilla.org/ssservice;1"]
       .getService(Ci.nsISiteSecurityService);
 
   // clear the current preload list
@@ -410,65 +100,69 @@ async function updatePinningList(records
       // 1254099.
     }
   }
 }
 
 /**
  * Write list of records into JSON file, and notify nsBlocklistService.
  *
- * @param {String} filename  path relative to profile dir.
- * @param {Object} records   current records in the local db.
+ * @param {Object} client   RemoteSettingsClient instance
+ * @param {Object} data      Current records in the local db.
  */
-async function updateJSONBlocklist(filename, records) {
+async function updateJSONBlocklist(client, { data: records }) {
   // Write JSON dump for synchronous load at startup.
-  const path = OS.Path.join(OS.Constants.Path.profileDir, filename);
+  const path = OS.Path.join(OS.Constants.Path.profileDir, client.filename);
   const blocklistFolder = OS.Path.dirname(path);
 
   await OS.File.makeDir(blocklistFolder, {from: OS.Constants.Path.profileDir});
 
   const serialized = JSON.stringify({data: records}, null, 2);
   try {
     await OS.File.writeAtomic(path, serialized, {tmpPath: path + ".tmp"});
     // Notify change to `nsBlocklistService`
-    const eventData = {filename};
+    const eventData = {filename: client.filename};
     Services.cpmm.sendAsyncMessage("Blocklist:reload-from-disk", eventData);
   } catch (e) {
     Cu.reportError(e);
   }
 }
 
-var OneCRLBlocklistClient = new BlocklistClient(
-  Services.prefs.getCharPref(PREF_BLOCKLIST_ONECRL_COLLECTION),
-  PREF_BLOCKLIST_ONECRL_CHECKED_SECONDS,
-  updateCertBlocklist,
-  Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET),
-  "onecrl.content-signature.mozilla.org"
-);
+var AddonBlocklistClient;
+var GfxBlocklistClient;
+var OneCRLBlocklistClient;
+var PinningBlocklistClient;
+var PluginBlocklistClient;
 
-var AddonBlocklistClient = new BlocklistClient(
-  Services.prefs.getCharPref(PREF_BLOCKLIST_ADDONS_COLLECTION),
-  PREF_BLOCKLIST_ADDONS_CHECKED_SECONDS,
-  (records) => updateJSONBlocklist(this.AddonBlocklistClient.filename, records),
-  Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET)
-);
+function initialize() {
+  OneCRLBlocklistClient = RemoteSettings(Services.prefs.getCharPref(PREF_BLOCKLIST_ONECRL_COLLECTION), {
+    bucketName: Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET),
+    lastCheckTimePref: PREF_BLOCKLIST_ONECRL_CHECKED_SECONDS,
+    signerName: "onecrl.content-signature.mozilla.org",
+  });
+  OneCRLBlocklistClient.on("change", updateCertBlocklist);
+
+  AddonBlocklistClient = RemoteSettings(Services.prefs.getCharPref(PREF_BLOCKLIST_ADDONS_COLLECTION), {
+    bucketName: Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET),
+    lastCheckTimePref: PREF_BLOCKLIST_ADDONS_CHECKED_SECONDS,
+  });
+  AddonBlocklistClient.on("change", updateJSONBlocklist.bind(null, AddonBlocklistClient));
 
-var GfxBlocklistClient = new BlocklistClient(
-  Services.prefs.getCharPref(PREF_BLOCKLIST_GFX_COLLECTION),
-  PREF_BLOCKLIST_GFX_CHECKED_SECONDS,
-  (records) => updateJSONBlocklist(this.GfxBlocklistClient.filename, records),
-  Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET)
-);
+  PluginBlocklistClient = RemoteSettings(Services.prefs.getCharPref(PREF_BLOCKLIST_PLUGINS_COLLECTION), {
+    bucketName: Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET),
+    lastCheckTimePref: PREF_BLOCKLIST_PLUGINS_CHECKED_SECONDS,
+  });
+  PluginBlocklistClient.on("change", updateJSONBlocklist.bind(null, PluginBlocklistClient));
 
-var PluginBlocklistClient = new BlocklistClient(
-  Services.prefs.getCharPref(PREF_BLOCKLIST_PLUGINS_COLLECTION),
-  PREF_BLOCKLIST_PLUGINS_CHECKED_SECONDS,
-  (records) => updateJSONBlocklist(this.PluginBlocklistClient.filename, records),
-  Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET)
-);
+  GfxBlocklistClient = RemoteSettings(Services.prefs.getCharPref(PREF_BLOCKLIST_GFX_COLLECTION), {
+    bucketName: Services.prefs.getCharPref(PREF_BLOCKLIST_BUCKET),
+    lastCheckTimePref: PREF_BLOCKLIST_GFX_CHECKED_SECONDS,
+  });
+  GfxBlocklistClient.on("change", updateJSONBlocklist.bind(null, GfxBlocklistClient));
 
-var PinningPreloadClient = new BlocklistClient(
-  Services.prefs.getCharPref(PREF_BLOCKLIST_PINNING_COLLECTION),
-  PREF_BLOCKLIST_PINNING_CHECKED_SECONDS,
-  updatePinningList,
-  Services.prefs.getCharPref(PREF_BLOCKLIST_PINNING_BUCKET),
-  "pinning-preload.content-signature.mozilla.org"
-);
+  PinningBlocklistClient = RemoteSettings(Services.prefs.getCharPref(PREF_BLOCKLIST_PINNING_COLLECTION), {
+    bucketName: Services.prefs.getCharPref(PREF_BLOCKLIST_PINNING_BUCKET),
+    lastCheckTimePref: PREF_BLOCKLIST_PINNING_CHECKED_SECONDS,
+    signerName: "pinning-preload.content-signature.mozilla.org",
+  });
+  PinningBlocklistClient.on("change", updatePinningList);
+}
+
deleted file mode 100644
--- a/services/common/blocklist-updater.js
+++ /dev/null
@@ -1,184 +0,0 @@
-/* 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/. */
-
-var EXPORTED_SYMBOLS = ["checkVersions", "addTestBlocklistClient"];
-
-const CC = Components.Constructor;
-
-ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
-ChromeUtils.import("resource://gre/modules/Services.jsm");
-Cu.importGlobalProperties(["fetch"]);
-ChromeUtils.defineModuleGetter(this, "UptakeTelemetry",
-                               "resource://services-common/uptake-telemetry.js");
-
-const PREF_SETTINGS_SERVER              = "services.settings.server";
-const PREF_SETTINGS_SERVER_BACKOFF      = "services.settings.server.backoff";
-const PREF_BLOCKLIST_CHANGES_PATH       = "services.blocklist.changes.path";
-const PREF_BLOCKLIST_LAST_UPDATE        = "services.blocklist.last_update_seconds";
-const PREF_BLOCKLIST_LAST_ETAG          = "services.blocklist.last_etag";
-const PREF_BLOCKLIST_CLOCK_SKEW_SECONDS = "services.blocklist.clock_skew_seconds";
-const PREF_BLOCKLIST_LOAD_DUMP          = "services.blocklist.load_dump";
-
-// Telemetry update source identifier.
-const TELEMETRY_HISTOGRAM_KEY = "settings-changes-monitoring";
-
-
-XPCOMUtils.defineLazyGetter(this, "gBlocklistClients", function() {
-  const BlocklistClients = ChromeUtils.import("resource://services-common/blocklist-clients.js", {});
-  return {
-    [BlocklistClients.OneCRLBlocklistClient.collectionName]: BlocklistClients.OneCRLBlocklistClient,
-    [BlocklistClients.AddonBlocklistClient.collectionName]: BlocklistClients.AddonBlocklistClient,
-    [BlocklistClients.GfxBlocklistClient.collectionName]: BlocklistClients.GfxBlocklistClient,
-    [BlocklistClients.PluginBlocklistClient.collectionName]: BlocklistClients.PluginBlocklistClient,
-    [BlocklistClients.PinningPreloadClient.collectionName]: BlocklistClients.PinningPreloadClient,
-  };
-});
-
-// Add a blocklist client for testing purposes. Do not use for any other purpose
-var addTestBlocklistClient = (name, client) => { gBlocklistClients[name] = client; };
-
-
-async function pollChanges(url, lastEtag) {
-  //
-  // Fetch a versionInfo object from the server that looks like:
-  // {"data":[
-  //   {
-  //     "host":"kinto-ota.dev.mozaws.net",
-  //     "last_modified":1450717104423,
-  //     "bucket":"blocklists",
-  //     "collection":"certificates"
-  //    }]}
-
-  // Use ETag to obtain a `304 Not modified` when no change occurred.
-  const headers = {};
-  if (lastEtag) {
-    headers["If-None-Match"] = lastEtag;
-  }
-  const response = await fetch(url, {headers});
-
-  let versionInfo = [];
-  // If no changes since last time, go on with empty list of changes.
-  if (response.status != 304) {
-    let payload;
-    try {
-      payload = await response.json();
-    } catch (e) {}
-    if (!payload.hasOwnProperty("data")) {
-      // If the server is failing, the JSON response might not contain the
-      // expected data (e.g. error response - Bug 1259145)
-      throw new Error(`Server error response ${JSON.stringify(payload)}`);
-    }
-    versionInfo = payload.data;
-  }
-  // The server should always return ETag. But we've had situations where the CDN
-  // was interfering.
-  const currentEtag = response.headers.has("ETag") ? response.headers.get("ETag") : undefined;
-  const serverTimeMillis = Date.parse(response.headers.get("Date"));
-
-  // Check if the server asked the clients to back off.
-  let backoffSeconds;
-  if (response.headers.has("Backoff")) {
-    const value = parseInt(response.headers.get("Backoff"), 10);
-    if (!isNaN(value)) {
-      backoffSeconds = value;
-    }
-  }
-
-  return {versionInfo, currentEtag, serverTimeMillis, backoffSeconds};
-}
-
-
-// This is called by the ping mechanism.
-// returns a promise that rejects if something goes wrong
-var checkVersions = async function() {
-  // Check if the server backoff time is elapsed.
-  if (Services.prefs.prefHasUserValue(PREF_SETTINGS_SERVER_BACKOFF)) {
-    const backoffReleaseTime = Services.prefs.getCharPref(PREF_SETTINGS_SERVER_BACKOFF);
-    const remainingMilliseconds = parseInt(backoffReleaseTime, 10) - Date.now();
-    if (remainingMilliseconds > 0) {
-      // Backoff time has not elapsed yet.
-      UptakeTelemetry.report(TELEMETRY_HISTOGRAM_KEY,
-                             UptakeTelemetry.STATUS.BACKOFF);
-      throw new Error(`Server is asking clients to back off; retry in ${Math.ceil(remainingMilliseconds / 1000)}s.`);
-    } else {
-      Services.prefs.clearUserPref(PREF_SETTINGS_SERVER_BACKOFF);
-    }
-  }
-
-  // Right now, we only use the collection name and the last modified info
-  const kintoBase = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
-  const changesEndpoint = kintoBase + Services.prefs.getCharPref(PREF_BLOCKLIST_CHANGES_PATH);
-
-  let lastEtag;
-  if (Services.prefs.prefHasUserValue(PREF_BLOCKLIST_LAST_ETAG)) {
-    lastEtag = Services.prefs.getCharPref(PREF_BLOCKLIST_LAST_ETAG);
-  }
-
-  let pollResult;
-  try {
-    pollResult = await pollChanges(changesEndpoint, lastEtag);
-  } catch (e) {
-    // Report polling error to Uptake Telemetry.
-    let report;
-    if (/Server/.test(e.message)) {
-      report = UptakeTelemetry.STATUS.SERVER_ERROR;
-    } else if (/NetworkError/.test(e.message)) {
-      report = UptakeTelemetry.STATUS.NETWORK_ERROR;
-    } else {
-      report = UptakeTelemetry.STATUS.UNKNOWN_ERROR;
-    }
-    UptakeTelemetry.report(TELEMETRY_HISTOGRAM_KEY, report);
-    // No need to go further.
-    throw new Error(`Polling for changes failed: ${e.message}.`);
-  }
-
-  const {serverTimeMillis, versionInfo, currentEtag, backoffSeconds} = pollResult;
-
-  // Report polling success to Uptake Telemetry.
-  const report = versionInfo.length == 0 ? UptakeTelemetry.STATUS.UP_TO_DATE
-                                         : UptakeTelemetry.STATUS.SUCCESS;
-  UptakeTelemetry.report(TELEMETRY_HISTOGRAM_KEY, report);
-
-  // Check if the server asked the clients to back off (for next poll).
-  if (backoffSeconds) {
-    const backoffReleaseTime = Date.now() + backoffSeconds * 1000;
-    Services.prefs.setCharPref(PREF_SETTINGS_SERVER_BACKOFF, backoffReleaseTime);
-  }
-
-  // Record new update time and the difference between local and server time.
-  // Negative clockDifference means local time is behind server time
-  // by the absolute of that value in seconds (positive means it's ahead)
-  const clockDifference = Math.floor((Date.now() - serverTimeMillis) / 1000);
-  Services.prefs.setIntPref(PREF_BLOCKLIST_CLOCK_SKEW_SECONDS, clockDifference);
-  Services.prefs.setIntPref(PREF_BLOCKLIST_LAST_UPDATE, serverTimeMillis / 1000);
-
-  const loadDump = Services.prefs.getBoolPref(PREF_BLOCKLIST_LOAD_DUMP, true);
-  // Iterate through the collections version info and initiate a synchronization
-  // on the related blocklist client.
-  let firstError;
-  for (const collectionInfo of versionInfo) {
-    const {bucket, collection, last_modified: lastModified} = collectionInfo;
-    const client = gBlocklistClients[collection];
-    if (client && client.bucketName == bucket) {
-      try {
-        await client.maybeSync(lastModified, serverTimeMillis, {loadDump});
-      } catch (e) {
-        if (!firstError) {
-          firstError = e;
-        }
-      }
-    }
-  }
-  if (firstError) {
-    // cause the promise to reject by throwing the first observed error
-    throw firstError;
-  }
-
-  // Save current Etag for next poll.
-  if (currentEtag) {
-    Services.prefs.setCharPref(PREF_BLOCKLIST_LAST_ETAG, currentEtag);
-  }
-
-  Services.obs.notifyObservers(null, "blocklist-updater-versions-checked");
-};
--- a/services/common/kinto-storage-adapter.js
+++ b/services/common/kinto-storage-adapter.js
@@ -9,18 +9,16 @@
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 const { Sqlite } = ChromeUtils.import("resource://gre/modules/Sqlite.jsm", {});
 const { Kinto } = ChromeUtils.import("resource://services-common/kinto-offline-client.js", {});
 
-const SQLITE_PATH = "kinto.sqlite";
-
 /**
  * Filter and sort list against provided filters and order.
  *
  * @param  {Object} filters  The filters to apply.
  * @param  {String} order    The order to apply.
  * @param  {Array}  list     The list to reduce.
  * @return {Array}
  */
--- a/services/common/moz.build
+++ b/services/common/moz.build
@@ -11,22 +11,22 @@ TEST_DIRS += ['tests']
 
 EXTRA_COMPONENTS += [
     'servicesComponents.manifest',
 ]
 
 EXTRA_JS_MODULES['services-common'] += [
     'async.js',
     'blocklist-clients.js',
-    'blocklist-updater.js',
     'kinto-http-client.js',
     'kinto-offline-client.js',
     'kinto-storage-adapter.js',
     'logmanager.js',
     'observers.js',
+    'remote-settings.js',
     'rest.js',
     'uptake-telemetry.js',
     'utils.js',
 ]
 
 if CONFIG['MOZ_WIDGET_TOOLKIT'] != 'android':
     EXTRA_JS_MODULES['services-common'] += [
         'hawkclient.js',
new file mode 100644
--- /dev/null
+++ b/services/common/remote-settings.js
@@ -0,0 +1,502 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["RemoteSettings", "pollChanges"];
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm", {});
+Cu.importGlobalProperties(["fetch"]);
+
+ChromeUtils.defineModuleGetter(this, "Kinto",
+                               "resource://services-common/kinto-offline-client.js");
+ChromeUtils.defineModuleGetter(this, "KintoHttpClient",
+                               "resource://services-common/kinto-http-client.js");
+ChromeUtils.defineModuleGetter(this, "FirefoxAdapter",
+                               "resource://services-common/kinto-storage-adapter.js");
+ChromeUtils.defineModuleGetter(this, "CanonicalJSON",
+                               "resource://gre/modules/CanonicalJSON.jsm");
+ChromeUtils.defineModuleGetter(this, "UptakeTelemetry",
+                               "resource://services-common/uptake-telemetry.js");
+
+const PREF_SETTINGS_SERVER             = "services.settings.server";
+const PREF_SETTINGS_VERIFY_SIGNATURE   = "services.settings.verify_signature";
+const PREF_SETTINGS_SERVER_BACKOFF     = "services.settings.server.backoff";
+const PREF_SETTINGS_CHANGES_PATH       = "services.settings.changes.path";
+const PREF_SETTINGS_LAST_UPDATE        = "services.settings.last_update_seconds";
+const PREF_SETTINGS_LAST_ETAG          = "services.settings.last_etag";
+const PREF_SETTINGS_CLOCK_SKEW_SECONDS = "services.settings.clock_skew_seconds";
+const PREF_SETTINGS_LOAD_DUMP          = "services.settings.load_dump";
+
+// Telemetry update source identifier.
+const TELEMETRY_HISTOGRAM_KEY = "settings-changes-monitoring";
+
+const INVALID_SIGNATURE = "Invalid content/signature";
+
+// This was the default path in earlier versions of
+// FirefoxAdapter, so for backwards compatibility we maintain this
+// filename, even though it isn't descriptive of who is using it.
+const KINTO_STORAGE_PATH = "kinto.sqlite";
+
+const gRemoteSettingsClients = new Map();
+
+
+// Get or instantiate a remote settings client.
+function RemoteSettings(collectionName, options) {
+  const { bucketName } = options;
+  const key = `${bucketName}/${collectionName}`;
+  if (!gRemoteSettingsClients.has(key)) {
+    const c = new RemoteSettingsClient(collectionName, options);
+    gRemoteSettingsClients.set(key, c);
+  }
+  return gRemoteSettingsClients.get(key);
+}
+
+
+function mergeChanges(collection, localRecords, changes) {
+  const records = {};
+  // Local records by id.
+  localRecords.forEach((record) => records[record.id] = collection.cleanLocalFields(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)
+    // Sort list by record id.
+    .sort((a, b) => {
+      if (a.id < b.id) {
+        return -1;
+      }
+      return a.id > b.id ? 1 : 0;
+    });
+}
+
+
+function fetchCollectionMetadata(remote, collection) {
+  const client = new KintoHttpClient(remote);
+  return client.bucket(collection.bucket).collection(collection.name).getData()
+    .then(result => {
+      return result.signature;
+    });
+}
+
+function fetchRemoteCollection(remote, collection) {
+  const client = new KintoHttpClient(remote);
+  return client.bucket(collection.bucket)
+           .collection(collection.name)
+           .listRecords({sort: "id"});
+}
+
+async function fetchLatestChanges(url, lastEtag) {
+  //
+  // Fetch the list of changes objects from the server that looks like:
+  // {"data":[
+  //   {
+  //     "host":"kinto-ota.dev.mozaws.net",
+  //     "last_modified":1450717104423,
+  //     "bucket":"blocklists",
+  //     "collection":"certificates"
+  //    }]}
+
+  // Use ETag to obtain a `304 Not modified` when no change occurred.
+  const headers = {};
+  if (lastEtag) {
+    headers["If-None-Match"] = lastEtag;
+  }
+  const response = await fetch(url, {headers});
+
+  let changes = [];
+  // If no changes since last time, go on with empty list of changes.
+  if (response.status != 304) {
+    let payload;
+    try {
+      payload = await response.json();
+    } catch (e) {}
+    if (!payload.hasOwnProperty("data")) {
+      // If the server is failing, the JSON response might not contain the
+      // expected data (e.g. error response - Bug 1259145)
+      throw new Error(`Server error response ${JSON.stringify(payload)}`);
+    }
+    changes = payload.data;
+  }
+  // The server should always return ETag. But we've had situations where the CDN
+  // was interfering.
+  const currentEtag = response.headers.has("ETag") ? response.headers.get("ETag") : undefined;
+  const serverTimeMillis = Date.parse(response.headers.get("Date"));
+
+  // Check if the server asked the clients to back off.
+  let backoffSeconds;
+  if (response.headers.has("Backoff")) {
+    const value = parseInt(response.headers.get("Backoff"), 10);
+    if (!isNaN(value)) {
+      backoffSeconds = value;
+    }
+  }
+
+  return {changes, currentEtag, serverTimeMillis, backoffSeconds};
+}
+
+
+class RemoteSettingsClient {
+
+  constructor(collectionName, { lastCheckTimePref, bucketName, signerName }) {
+    this.collectionName = collectionName;
+    this.lastCheckTimePref = lastCheckTimePref;
+    this.bucketName = bucketName;
+    this.signerName = signerName;
+
+    this._callbacks = new Map();
+    this._callbacks.set("change", []);
+
+    this._kinto = null;
+  }
+
+  get identifier() {
+    return `${this.bucketName}/${this.collectionName}`;
+  }
+
+  get filename() {
+    // Replace slash by OS specific path separator (eg. Windows)
+    const identifier = OS.Path.join(...this.identifier.split("/"));
+    return `${identifier}.json`;
+  }
+
+  on(event, callback) {
+    if (!this._callbacks.has(event)) {
+      throw new Error(`Unknown event type ${event}`);
+    }
+    this._callbacks.get(event).push(callback);
+  }
+
+  /**
+   * Open the underlying Kinto collection, using the appropriate adapter and
+   * options. This acts as a context manager where the connection is closed
+   * once the specified `callback` has finished.
+   *
+   * @param {callback} function           the async function to execute with the open SQlite connection.
+   * @param {Object}   options            additional advanced options.
+   * @param {string}   options.bucket     override bucket name of client (default: this.bucketName)
+   * @param {string}   options.collection override collection name of client (default: this.collectionName)
+   * @param {string}   options.path       override default Sqlite path (default: kinto.sqlite)
+   * @param {string}   options.hooks      hooks to execute on synchronization (see Kinto.js docs)
+   */
+  async openCollection(callback, options = {}) {
+    const { bucket = this.bucketName, path = KINTO_STORAGE_PATH } = options;
+    if (!this._kinto) {
+      this._kinto = new Kinto({bucket, adapter: FirefoxAdapter});
+    }
+    let sqliteHandle;
+    try {
+      sqliteHandle = await FirefoxAdapter.openConnection({path});
+      const colOptions = Object.assign({adapterOptions: {sqliteHandle}}, options);
+      const {collection: collectionName = this.collectionName} = options;
+      const collection = this._kinto.collection(collectionName, colOptions);
+      return await callback(collection);
+    } finally {
+      if (sqliteHandle) {
+        await sqliteHandle.close();
+      }
+    }
+  }
+
+  /**
+   * 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.
+   * @param {Object} options        additional advanced options.
+   * @param {bool} options.loadDump load initial dump from disk on first sync (default: true)
+   * @return {Promise}              which rejects on sync or process failure.
+   */
+  async maybeSync(lastModified, serverTime, options = {loadDump: true}) {
+    const {loadDump} = options;
+    const remote = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
+    const verifySignature = Services.prefs.getBoolPref(PREF_SETTINGS_VERIFY_SIGNATURE, true);
+
+    // if there is a signerName and collection signing is enforced, add a
+    // hook for incoming changes that validates the signature
+    const colOptions = {};
+    if (this.signerName && verifySignature) {
+      colOptions.hooks = {
+        "incoming-changes": [(payload, collection) => {
+          return this._validateCollectionSignature(remote, payload, collection);
+        }]
+      };
+    }
+
+    let reportStatus = null;
+    try {
+      return await this.openCollection(async (collection) => {
+        // Synchronize remote data into a local Sqlite DB.
+        let collectionLastModified = await collection.db.getLastModified();
+
+        // If there is no data currently in the collection, attempt to import
+        // initial data from the application defaults.
+        // This allows to avoid synchronizing the whole collection content on
+        // cold start.
+        if (!collectionLastModified && loadDump) {
+          try {
+            const initialData = await this._loadDumpFile();
+            await collection.loadDump(initialData.data);
+            collectionLastModified = await collection.db.getLastModified();
+          } catch (e) {
+            // Report but go-on.
+            Cu.reportError(e);
+          }
+        }
+
+        // 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);
+          reportStatus = UptakeTelemetry.STATUS.UP_TO_DATE;
+          return;
+        }
+
+        // Fetch changes from server.
+        try {
+          // Server changes have priority during synchronization.
+          const strategy = Kinto.syncStrategy.SERVER_WINS;
+          const {ok} = await collection.sync({remote, strategy});
+          if (!ok) {
+            // Some synchronization conflicts occured.
+            reportStatus = UptakeTelemetry.STATUS.CONFLICT_ERROR;
+            throw new Error("Sync failed");
+          }
+        } catch (e) {
+          if (e.message == INVALID_SIGNATURE) {
+            // Signature verification failed during synchronzation.
+            reportStatus = UptakeTelemetry.STATUS.SIGNATURE_ERROR;
+            // 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.
+            const payload = await fetchRemoteCollection(remote, collection);
+            try {
+              await this._validateCollectionSignature(remote, payload, collection, {ignoreLocal: true});
+            } catch (e) {
+              reportStatus = UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR;
+              throw e;
+            }
+            // if the signature is good (we haven't thrown), and the remote
+            // last_modified is newer than the local last_modified, replace the
+            // local data
+            const localLastModified = await collection.db.getLastModified();
+            if (payload.last_modified >= localLastModified) {
+              await collection.clear();
+              await collection.loadDump(payload.data);
+            }
+          } else {
+            // The sync has thrown, it can be a network or a general error.
+            if (/NetworkError/.test(e.message)) {
+              reportStatus = UptakeTelemetry.STATUS.NETWORK_ERROR;
+            } else if (/Backoff/.test(e.message)) {
+              reportStatus = UptakeTelemetry.STATUS.BACKOFF;
+            } else {
+              reportStatus = UptakeTelemetry.STATUS.SYNC_ERROR;
+            }
+            throw e;
+          }
+        }
+        // Read local collection of records.
+        const {data} = await collection.list();
+
+        // Handle the obtained records (ie. apply locally).
+        try {
+          // Execute callbacks in order and sequentially.
+          // If one fails everything fails.
+          const callbacks = this._callbacks.get("change");
+          for (const cb of callbacks) {
+            await cb({ data });
+          }
+        } catch (e) {
+          reportStatus = UptakeTelemetry.STATUS.APPLY_ERROR;
+          throw e;
+        }
+
+        // Track last update.
+        this._updateLastCheck(serverTime);
+
+      }, colOptions);
+    } catch (e) {
+      // No specific error was tracked, mark it as unknown.
+      if (reportStatus === null) {
+        reportStatus = UptakeTelemetry.STATUS.UNKNOWN_ERROR;
+      }
+      throw e;
+    } finally {
+      // No error was reported, this is a success!
+      if (reportStatus === null) {
+        reportStatus = UptakeTelemetry.STATUS.SUCCESS;
+      }
+      // Report success/error status to Telemetry.
+      UptakeTelemetry.report(this.identifier, reportStatus);
+    }
+  }
+
+  /**
+   * Load the the JSON file distributed with the release for this collection.
+   */
+  async _loadDumpFile() {
+    // Replace OS specific path separator by / for URI.
+    const { components: folderFile } = OS.Path.split(this.filename);
+    const fileURI = `resource://app/defaults/${folderFile.join("/")}`;
+    const response = await fetch(fileURI);
+    if (!response.ok) {
+      throw new Error(`Could not read from '${fileURI}'`);
+    }
+    // Will be rejected if JSON is invalid.
+    return response.json();
+  }
+
+  async _validateCollectionSignature(remote, payload, collection, options = {}) {
+    const {ignoreLocal} = options;
+
+    // this is a content-signature field from an autograph response.
+    const {x5u, signature} = await fetchCollectionMetadata(remote, collection);
+    const certChainResponse = await fetch(x5u);
+    const certChain = await certChainResponse.text();
+
+    const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
+                       .createInstance(Ci.nsIContentSignatureVerifier);
+
+    let toSerialize;
+    if (ignoreLocal) {
+      toSerialize = {
+        last_modified: `${payload.last_modified}`,
+        data: payload.data
+      };
+    } else {
+      const {data: localRecords} = await collection.list();
+      const records = mergeChanges(collection, localRecords, payload.changes);
+      toSerialize = {
+        last_modified: `${payload.lastModified}`,
+        data: records
+      };
+    }
+
+    const serialized = CanonicalJSON.stringify(toSerialize);
+
+    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);
+  }
+
+  /**
+   * Save last time server was checked in users prefs.
+   *
+   * @param {Date} serverTime   the current date return by server.
+   */
+  _updateLastCheck(serverTime) {
+    const checkedServerTimeInSeconds = Math.round(serverTime / 1000);
+    Services.prefs.setIntPref(this.lastCheckTimePref, checkedServerTimeInSeconds);
+  }
+}
+
+// This is called by the ping mechanism.
+// returns a promise that rejects if something goes wrong
+async function pollChanges() {
+  // Check if the server backoff time is elapsed.
+  if (Services.prefs.prefHasUserValue(PREF_SETTINGS_SERVER_BACKOFF)) {
+    const backoffReleaseTime = Services.prefs.getCharPref(PREF_SETTINGS_SERVER_BACKOFF);
+    const remainingMilliseconds = parseInt(backoffReleaseTime, 10) - Date.now();
+    if (remainingMilliseconds > 0) {
+      // Backoff time has not elapsed yet.
+      UptakeTelemetry.report(TELEMETRY_HISTOGRAM_KEY,
+                             UptakeTelemetry.STATUS.BACKOFF);
+      throw new Error(`Server is asking clients to back off; retry in ${Math.ceil(remainingMilliseconds / 1000)}s.`);
+    } else {
+      Services.prefs.clearUserPref(PREF_SETTINGS_SERVER_BACKOFF);
+    }
+  }
+
+  // Right now, we only use the collection name and the last modified info
+  const kintoBase = Services.prefs.getCharPref(PREF_SETTINGS_SERVER);
+  const changesEndpoint = kintoBase + Services.prefs.getCharPref(PREF_SETTINGS_CHANGES_PATH);
+
+  let lastEtag;
+  if (Services.prefs.prefHasUserValue(PREF_SETTINGS_LAST_ETAG)) {
+    lastEtag = Services.prefs.getCharPref(PREF_SETTINGS_LAST_ETAG);
+  }
+
+  let pollResult;
+  try {
+    pollResult = await fetchLatestChanges(changesEndpoint, lastEtag);
+  } catch (e) {
+    // Report polling error to Uptake Telemetry.
+    let report;
+    if (/Server/.test(e.message)) {
+      report = UptakeTelemetry.STATUS.SERVER_ERROR;
+    } else if (/NetworkError/.test(e.message)) {
+      report = UptakeTelemetry.STATUS.NETWORK_ERROR;
+    } else {
+      report = UptakeTelemetry.STATUS.UNKNOWN_ERROR;
+    }
+    UptakeTelemetry.report(TELEMETRY_HISTOGRAM_KEY, report);
+    // No need to go further.
+    throw new Error(`Polling for changes failed: ${e.message}.`);
+  }
+
+  const {serverTimeMillis, changes, currentEtag, backoffSeconds} = pollResult;
+
+  // Report polling success to Uptake Telemetry.
+  const report = changes.length == 0 ? UptakeTelemetry.STATUS.UP_TO_DATE
+                                     : UptakeTelemetry.STATUS.SUCCESS;
+  UptakeTelemetry.report(TELEMETRY_HISTOGRAM_KEY, report);
+
+  // Check if the server asked the clients to back off (for next poll).
+  if (backoffSeconds) {
+    const backoffReleaseTime = Date.now() + backoffSeconds * 1000;
+    Services.prefs.setCharPref(PREF_SETTINGS_SERVER_BACKOFF, backoffReleaseTime);
+  }
+
+  // Record new update time and the difference between local and server time.
+  // Negative clockDifference means local time is behind server time
+  // by the absolute of that value in seconds (positive means it's ahead)
+  const clockDifference = Math.floor((Date.now() - serverTimeMillis) / 1000);
+  Services.prefs.setIntPref(PREF_SETTINGS_CLOCK_SKEW_SECONDS, clockDifference);
+  Services.prefs.setIntPref(PREF_SETTINGS_LAST_UPDATE, serverTimeMillis / 1000);
+
+  const loadDump = Services.prefs.getBoolPref(PREF_SETTINGS_LOAD_DUMP, true);
+  // Iterate through the collections version info and initiate a synchronization
+  // on the related remote settings client.
+  let firstError;
+  for (const change of changes) {
+    const {bucket, collection, last_modified: lastModified} = change;
+    const key = `${bucket}/${collection}`;
+    if (!gRemoteSettingsClients.has(key)) {
+      continue;
+    }
+    const client = gRemoteSettingsClients.get(key);
+    if (client.bucketName != bucket) {
+      continue;
+    }
+    try {
+      await client.maybeSync(lastModified, serverTimeMillis, {loadDump});
+    } catch (e) {
+      if (!firstError) {
+        firstError = e;
+      }
+    }
+  }
+  if (firstError) {
+    // cause the promise to reject by throwing the first observed error
+    throw firstError;
+  }
+
+  // Save current Etag for next poll.
+  if (currentEtag) {
+    Services.prefs.setCharPref(PREF_SETTINGS_LAST_ETAG, currentEtag);
+  }
+
+  Services.obs.notifyObservers(null, "remote-settings-changes-polled");
+}
--- a/services/common/tests/unit/test_blocklist_certificates.js
+++ b/services/common/tests/unit/test_blocklist_certificates.js
@@ -1,13 +1,14 @@
 const { Constructor: CC } = Components;
 
+ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://testing-common/httpd.js");
 
-const { OneCRLBlocklistClient } = ChromeUtils.import("resource://services-common/blocklist-clients.js", {});
+const BlocklistClients = ChromeUtils.import("resource://services-common/blocklist-clients.js", {});
 
 const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
   "nsIBinaryInputStream", "setInputStream");
 
 let server;
 
 // Some simple tests to demonstrate that the logic inside maybeSync works
 // correctly and that simple kinto operations are working as expected. There
@@ -15,16 +16,20 @@ let server;
 // xpcshell tests under /services/common
 add_task(async function test_something() {
   const configPath = "/v1/";
   const recordsPath = "/v1/buckets/blocklists/collections/certificates/records";
 
   const dummyServerURL = `http://localhost:${server.identity.primaryPort}/v1`;
   Services.prefs.setCharPref("services.settings.server", dummyServerURL);
 
+  BlocklistClients.initialize();
+
+  const OneCRLBlocklistClient = BlocklistClients.OneCRLBlocklistClient;
+
   // register a handler
   function handleResponse(request, response) {
     try {
       const sample = getSampleResponse(request, server.identity.primaryPort);
       if (!sample) {
         do_throw(`unexpected ${request.method} request for ${request.path}?${request.queryString}`);
       }
 
@@ -116,17 +121,17 @@ add_task(async function test_something()
   // acceptible test.
   Services.prefs.setCharPref("services.settings.server", dummyServerURL);
   await OneCRLBlocklistClient.maybeSync(5000, Date.now());
 });
 
 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);
+  Services.prefs.setBoolPref("services.settings.verify_signature", false);
 
   // Set up an HTTP Server
   server = new HttpServer();
   server.start(-1);
 
   run_next_test();
 
   registerCleanupFunction(function() {
--- a/services/common/tests/unit/test_blocklist_clients.js
+++ b/services/common/tests/unit/test_blocklist_clients.js
@@ -7,23 +7,18 @@ const { FileUtils } = ChromeUtils.import
 const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm", {});
 
 const BlocklistClients = ChromeUtils.import("resource://services-common/blocklist-clients.js", {});
 const { UptakeTelemetry } = ChromeUtils.import("resource://services-common/uptake-telemetry.js", {});
 
 const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
   "nsIBinaryInputStream", "setInputStream");
 
-const gBlocklistClients = [
-  {client: BlocklistClients.AddonBlocklistClient, testData: ["i808", "i720", "i539"]},
-  {client: BlocklistClients.PluginBlocklistClient, testData: ["p1044", "p32", "p28"]},
-  {client: BlocklistClients.GfxBlocklistClient, testData: ["g204", "g200", "g36"]},
-];
 
-
+let gBlocklistClients;
 let server;
 
 async function readJSON(filepath) {
   const binaryData = await OS.File.read(filepath);
   const textData = (new TextDecoder()).decode(binaryData);
   return Promise.resolve(JSON.parse(textData));
 }
 
@@ -49,16 +44,25 @@ function run_test() {
   // Set up an HTTP Server
   server = new HttpServer();
   server.start(-1);
 
   // Point the blocklist clients to use this local HTTP server.
   Services.prefs.setCharPref("services.settings.server",
                              `http://localhost:${server.identity.primaryPort}/v1`);
 
+  // This will initialize the remote settings clients for blocklists.
+  BlocklistClients.initialize();
+
+  gBlocklistClients = [
+    {client: BlocklistClients.AddonBlocklistClient, testData: ["i808", "i720", "i539"]},
+    {client: BlocklistClients.PluginBlocklistClient, testData: ["p1044", "p32", "p28"]},
+    {client: BlocklistClients.GfxBlocklistClient, testData: ["g204", "g200", "g36"]},
+  ];
+
   // Setup server fake responses.
   function handleResponse(request, response) {
     try {
       const sample = getSampleResponse(request, server.identity.primaryPort);
       if (!sample) {
         do_throw(`unexpected ${request.method} request for ${request.path}?${request.queryString}`);
       }
 
@@ -236,25 +240,22 @@ add_task(async function test_telemetry_i
   }
 });
 add_task(clear_state);
 
 add_task(async function test_telemetry_reports_if_application_fails() {
   const {client} = gBlocklistClients[0];
   const serverTime = Date.now();
   const startHistogram = getUptakeTelemetrySnapshot(client.identifier);
-  const backup = client.processCallback;
-  client.processCallback = () => { throw new Error("boom"); };
+  client.on("change", () => { throw new Error("boom"); });
 
   try {
     await client.maybeSync(2000, serverTime, {loadDump: false});
   } catch (e) {}
 
-  client.processCallback = backup;
-
   const endHistogram = getUptakeTelemetrySnapshot(client.identifier);
   const expectedIncrements = {[UptakeTelemetry.STATUS.APPLY_ERROR]: 1};
   checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
 });
 add_task(clear_state);
 
 add_task(async function test_telemetry_reports_if_sync_fails() {
   const {client} = gBlocklistClients[0];
--- a/services/common/tests/unit/test_blocklist_pinning.js
+++ b/services/common/tests/unit/test_blocklist_pinning.js
@@ -1,13 +1,15 @@
 "use strict";
 
 const { Constructor: CC } = Components;
 
+ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://testing-common/httpd.js");
+const BlocklistClients = ChromeUtils.import("resource://services-common/blocklist-clients.js", {});
 
 const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
   "nsIBinaryInputStream", "setInputStream");
 
 // First, we need to setup Services.appinfo or we can't do version checks
 var id = "xpcshell@tests.mozilla.org";
 var appName = "XPCShell";
 var version = "1";
@@ -22,17 +24,19 @@ updateAppInfo({
   crashReporter: true,
 });
 
 let server;
 
 // Some simple tests to demonstrate that the core preload sync operations work
 // correctly and that simple kinto operations are working as expected.
 add_task(async function test_something() {
-  const { PinningPreloadClient } = ChromeUtils.import("resource://services-common/blocklist-clients.js", {});
+  BlocklistClients.initialize();
+
+  const PinningPreloadClient = BlocklistClients.PinningBlocklistClient;
 
   const configPath = "/v1/";
   const recordsPath = "/v1/buckets/pinning/collections/pins/records";
 
   Services.prefs.setCharPref("services.settings.server",
                              `http://localhost:${server.identity.primaryPort}/v1`);
 
   // register a handler
@@ -146,17 +150,17 @@ add_task(async function test_something()
   ok(sss.isSecureURI(sss.HEADER_HSTS,
                      Services.io.newURI("https://subdomain.five.example.com"),
                      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);
+  Services.prefs.setBoolPref("services.settings.verify_signature", false);
 
   // Set up an HTTP Server
   server = new HttpServer();
   server.start(-1);
 
   run_next_test();
 
   registerCleanupFunction(function() {
--- a/services/common/tests/unit/test_blocklist_signatures.js
+++ b/services/common/tests/unit/test_blocklist_signatures.js
@@ -1,26 +1,23 @@
 "use strict";
 
-ChromeUtils.import("resource://services-common/blocklist-updater.js");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://testing-common/httpd.js");
 
 const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm", {});
-const { OneCRLBlocklistClient } = ChromeUtils.import("resource://services-common/blocklist-clients.js", {});
+const BlocklistClients = ChromeUtils.import("resource://services-common/blocklist-clients.js", {});
 const { UptakeTelemetry } = ChromeUtils.import("resource://services-common/uptake-telemetry.js", {});
 
 let server;
 
-const PREF_BLOCKLIST_ENFORCE_SIGNING   = "services.blocklist.signing.enforced";
+const PREF_SETTINGS_VERIFY_SIGNATURE   = "services.settings.verify_signature";
 const PREF_SETTINGS_SERVER             = "services.settings.server";
 const PREF_SIGNATURE_ROOT              = "security.content.signature.root_hash";
 
-// Telemetry reports.
-const TELEMETRY_HISTOGRAM_KEY = OneCRLBlocklistClient.identifier;
-
 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"]
@@ -48,29 +45,34 @@ function setRoot() {
 function getCertChain() {
   const chain = [];
   for (let file of CHAIN_FILES) {
     chain.push(getFileData(do_get_file(CERT_DIR + file)));
   }
   return chain.join("\n");
 }
 
-async function checkRecordCount(count) {
-  await OneCRLBlocklistClient.openCollection(async (collection) => {
+async function checkRecordCount(client, count) {
+  await client.openCollection(async (collection) => {
     // Check we have the expected number of records
     const records = await collection.list();
     Assert.equal(count, records.data.length);
   });
 }
 
 // Check to ensure maybeSync is called with correct values when a changes
 // document contains information on when a collection was last modified
 add_task(async function test_check_signatures() {
   const port = server.identity.primaryPort;
 
+  const OneCRLBlocklistClient = BlocklistClients.OneCRLBlocklistClient;
+
+  // Telemetry reports.
+  const TELEMETRY_HISTOGRAM_KEY = OneCRLBlocklistClient.identifier;
+
   // 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`,
@@ -457,17 +459,17 @@ add_task(async function test_check_signa
     // checked against the valid signature and last_modified times will be
     // compared. Sync should fail, even though the signature is good,
     // because the local collection is newer.
     "GET:/v1/buckets/blocklists/collections/certificates/records?_sort=id":
       [RESPONSE_EMPTY_INITIAL],
   };
 
   // ensure our collection hasn't been replaced with an older, empty one
-  await checkRecordCount(2);
+  await checkRecordCount(OneCRLBlocklistClient, 2);
 
   registerHandlers(badSigGoodOldResponses);
   await 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],
@@ -483,28 +485,30 @@ add_task(async function test_check_signa
   };
 
   startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
   registerHandlers(allBadSigResponses);
   try {
     await OneCRLBlocklistClient.maybeSync(6000, startTime);
     do_throw("Sync should fail (the signature is intentionally bad)");
   } catch (e) {
-    await checkRecordCount(2);
+    await checkRecordCount(OneCRLBlocklistClient, 2);
   }
 
   // Ensure that the failure is reflected in the accumulated telemetry:
   endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
   expectedIncrements = {[UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR]: 1};
   checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
 });
 
 function run_test() {
+  BlocklistClients.initialize();
+
   // ensure signatures are enforced
-  Services.prefs.setBoolPref(PREF_BLOCKLIST_ENFORCE_SIGNING, true);
+  Services.prefs.setBoolPref(PREF_SETTINGS_VERIFY_SIGNATURE, 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();
 
rename from services/common/tests/unit/test_blocklist_updater.js
rename to services/common/tests/unit/test_remote_settings_poll.js
--- a/services/common/tests/unit/test_blocklist_updater.js
+++ b/services/common/tests/unit/test_remote_settings_poll.js
@@ -1,18 +1,19 @@
 ChromeUtils.import("resource://testing-common/httpd.js");
 const { UptakeTelemetry } = ChromeUtils.import("resource://services-common/uptake-telemetry.js", {});
+const RemoteSettings = ChromeUtils.import("resource://services-common/remote-settings.js", {});
 
 var server;
 
 const PREF_SETTINGS_SERVER = "services.settings.server";
 const PREF_SETTINGS_SERVER_BACKOFF = "services.settings.server.backoff";
-const PREF_LAST_UPDATE = "services.blocklist.last_update_seconds";
-const PREF_LAST_ETAG = "services.blocklist.last_etag";
-const PREF_CLOCK_SKEW_SECONDS = "services.blocklist.clock_skew_seconds";
+const PREF_LAST_UPDATE = "services.settings.last_update_seconds";
+const PREF_LAST_ETAG = "services.settings.last_etag";
+const PREF_CLOCK_SKEW_SECONDS = "services.settings.clock_skew_seconds";
 
 // Telemetry report result.
 const TELEMETRY_HISTOGRAM_KEY = "settings-changes-monitoring";
 
 // Check to ensure maybeSync is called with correct values when a changes
 // document contains information on when a collection was last modified
 add_task(async function test_check_maybeSync() {
   const changesPath = "/v1/buckets/monitor/collections/changes/records";
@@ -51,44 +52,39 @@ add_task(async function test_check_maybe
   // set some initial values so we can check these are updated appropriately
   Services.prefs.setIntPref(PREF_LAST_UPDATE, 0);
   Services.prefs.setIntPref(PREF_CLOCK_SKEW_SECONDS, 0);
   Services.prefs.clearUserPref(PREF_LAST_ETAG);
 
 
   let startTime = Date.now();
 
-  let updater = ChromeUtils.import("resource://services-common/blocklist-updater.js", {});
-
   // ensure we get the maybeSync call
   // add a test kinto client that will respond to lastModified information
   // for a collection called 'test-collection'
-  updater.addTestBlocklistClient("test-collection", {
-    bucketName: "blocklists",
-    maybeSync(lastModified, serverTime) {
-      Assert.equal(lastModified, 1000);
-      Assert.equal(serverTime, 2000);
-    }
+  const c = RemoteSettings.RemoteSettings("test-collection", {
+    bucketName: "test-bucket",
   });
+  c.maybeSync = () => {};
 
   const startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
 
   let notificationObserved = false;
 
-  // Ensure that the blocklist-updater-versions-checked notification works
+  // Ensure that the remote-settings-changes-polled notification works
   let certblockObserver = {
     observe(aSubject, aTopic, aData) {
-      Services.obs.removeObserver(this, "blocklist-updater-versions-checked");
+      Services.obs.removeObserver(this, "remote-settings-changes-polled");
       notificationObserved = true;
     }
   };
 
-  Services.obs.addObserver(certblockObserver, "blocklist-updater-versions-checked");
+  Services.obs.addObserver(certblockObserver, "remote-settings-changes-polled");
 
-  await updater.checkVersions();
+  await RemoteSettings.pollChanges();
 
   Assert.ok(notificationObserved, "a notification should have been observed");
 
   // check the last_update is updated
   Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 2);
 
   // How does the clock difference look?
   let endTime = Date.now();
@@ -98,20 +94,18 @@ add_task(async function test_check_maybe
               && clockDifference >= Math.floor(startTime / 1000) - 2);
   // Last timestamp was saved. An ETag header value is a quoted string.
   let lastEtag = Services.prefs.getCharPref(PREF_LAST_ETAG);
   Assert.equal(lastEtag, "\"1100\"");
 
   // Simulate a poll with up-to-date collection.
   Services.prefs.setIntPref(PREF_LAST_UPDATE, 0);
   // If server has no change, a 304 is received, maybeSync() is not called.
-  updater.addTestBlocklistClient("test-collection", {
-    maybeSync: () => { throw new Error("Should not be called"); }
-  });
-  await updater.checkVersions();
+  c.maybeSync = () => { throw new Error("Should not be called"); };
+  await RemoteSettings.pollChanges();
   // Last update is overwritten
   Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 2);
 
 
   // Simulate a server error.
   function simulateErrorResponse(request, response) {
     response.setHeader("Date", (new Date(3000)).toUTCString());
     response.setHeader("Content-Type", "application/json; charset=UTF-8");
@@ -119,73 +113,73 @@ add_task(async function test_check_maybe
       code: 503,
       errno: 999,
       error: "Service Unavailable",
     }));
     response.setStatusLine(null, 503, "Service Unavailable");
   }
   server.registerPathHandler(changesPath, simulateErrorResponse);
 
-  // checkVersions() fails with adequate error and no notification.
+  // pollChanges() fails with adequate error and no notification.
   let error;
   notificationObserved = false;
-  Services.obs.addObserver(certblockObserver, "blocklist-updater-versions-checked");
+  Services.obs.addObserver(certblockObserver, "remote-settings-changes-polled");
   try {
-    await updater.checkVersions();
+    await RemoteSettings.pollChanges();
   } catch (e) {
     error = e;
   }
   Assert.ok(!notificationObserved, "a notification should not have been observed");
   Assert.ok(/Polling for changes failed/.test(error.message));
   // When an error occurs, last update was not overwritten (see Date header above).
   Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 2);
 
   // check negative clock skew times
 
   // set to a time in the future
   server.registerPathHandler(changesPath, handleResponse.bind(null, Date.now() + 10000));
 
-  await updater.checkVersions();
+  await RemoteSettings.pollChanges();
 
   clockDifference = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS);
   // we previously set the serverTime to Date.now() + 10000 ms past epoch
   Assert.ok(clockDifference <= 0 && clockDifference >= -10);
 
   //
   // Backoff
   //
   function simulateBackoffResponse(request, response) {
     response.setHeader("Content-Type", "application/json; charset=UTF-8");
     response.setHeader("Backoff", "10");
     response.write(JSON.stringify({data: []}));
     response.setStatusLine(null, 200, "OK");
   }
   server.registerPathHandler(changesPath, simulateBackoffResponse);
   // First will work.
-  await updater.checkVersions();
+  await RemoteSettings.pollChanges();
   // Second will fail because we haven't waited.
   try {
-    await updater.checkVersions();
+    await RemoteSettings.pollChanges();
     // The previous line should have thrown an error.
     Assert.ok(false);
   } catch (e) {
     Assert.ok(/Server is asking clients to back off; retry in \d+s./.test(e.message));
   }
   // Once backoff time has expired, polling for changes can start again.
   server.registerPathHandler(changesPath, handleResponse.bind(null, 2000));
   Services.prefs.setCharPref(PREF_SETTINGS_SERVER_BACKOFF, `${Date.now() - 1000}`);
-  await updater.checkVersions();
+  await RemoteSettings.pollChanges();
   // Backoff tracking preference was cleared.
   Assert.ok(!Services.prefs.prefHasUserValue(PREF_SETTINGS_SERVER_BACKOFF));
 
 
   // Simulate a network error (to check telemetry report).
   Services.prefs.setCharPref(PREF_SETTINGS_SERVER, "http://localhost:42/v1");
   try {
-    await updater.checkVersions();
+    await RemoteSettings.pollChanges();
   } catch (e) {}
 
   const endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
   // ensure that we've accumulated the correct telemetry
   const expectedIncrements = {
     [UptakeTelemetry.STATUS.UP_TO_DATE]: 4,
     [UptakeTelemetry.STATUS.SUCCESS]: 1,
     [UptakeTelemetry.STATUS.BACKOFF]: 1,
@@ -215,23 +209,23 @@ function getSampleResponse(req, port) {
       "sampleHeaders": [
         "Content-Type: application/json; charset=UTF-8",
         "ETag: \"1100\""
       ],
       "status": {status: 200, statusText: "OK"},
       "responseBody": JSON.stringify({"data": [{
         "host": "localhost",
         "last_modified": 1100,
-        "bucket": "blocklists:aurora",
+        "bucket": "test-bucket-aurora",
         "id": "330a0c5f-fadf-ff0b-40c8-4eb0d924ff6a",
         "collection": "test-collection"
       }, {
         "host": "localhost",
         "last_modified": 1000,
-        "bucket": "blocklists",
+        "bucket": "test-bucket",
         "id": "254cbb9e-6888-4d9f-8e60-58b74faa8778",
         "collection": "test-collection"
       }]})
     }
   };
 
   if (req.hasHeader("if-none-match") && req.getHeader("if-none-match", "") == "\"1100\"")
     return {sampleHeaders: [], status: {status: 304, statusText: "Not Modified"}, responseBody: ""};
--- a/services/common/tests/unit/xpcshell.ini
+++ b/services/common/tests/unit/xpcshell.ini
@@ -9,29 +9,29 @@ support-files =
 [test_load_modules.js]
 
 [test_blocklist_certificates.js]
 # Initial JSON data for blocklists are not shipped on Android.
 skip-if = (os == "android" || appname == "thunderbird")
 tags = blocklist
 [test_blocklist_clients.js]
 tags = blocklist
-[test_blocklist_updater.js]
-tags = blocklist
 [test_blocklist_pinning.js]
 tags = blocklist
+[test_remote_settings_poll.js]
+tags = remote-settings blocklist
 
 [test_kinto.js]
 tags = blocklist
 [test_blocklist_signatures.js]
-tags = blocklist
+tags = remote-settings blocklist
 [test_storage_adapter.js]
-tags = blocklist
+tags = remote-settingsblocklist
 [test_storage_adapter_shutdown.js]
-tags = blocklist
+tags = remote-settings blocklist
 
 [test_utils_atob.js]
 [test_utils_convert_string.js]
 [test_utils_dateprefs.js]
 [test_utils_encodeBase32.js]
 [test_utils_encodeBase64URL.js]
 [test_utils_ensureMillisecondsTimestamp.js]
 [test_utils_json.js]
--- a/toolkit/mozapps/extensions/nsBlocklistService.js
+++ b/toolkit/mozapps/extensions/nsBlocklistService.js
@@ -23,22 +23,26 @@ ChromeUtils.defineModuleGetter(this, "Fi
                                "resource://gre/modules/FileUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "UpdateUtils",
                                "resource://gre/modules/UpdateUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "OS",
                                "resource://gre/modules/osfile.jsm");
 ChromeUtils.defineModuleGetter(this, "ServiceRequest",
                                "resource://gre/modules/ServiceRequest.jsm");
 
-// The blocklist updater is the new system in charge of fetching remote data
+// The remote settings updater is the new system in charge of fetching remote data
 // securely and efficiently. It will replace the current XML-based system.
 // See Bug 1257565 and Bug 1252456.
-const BlocklistUpdater = {};
-ChromeUtils.defineModuleGetter(BlocklistUpdater, "checkVersions",
-                               "resource://services-common/blocklist-updater.js");
+const BlocklistClients = ChromeUtils.import("resource://services-common/blocklist-clients.js", {});
+XPCOMUtils.defineLazyGetter(this, "RemoteSettings", function() {
+  // Instantiate blocklist clients.
+  BlocklistClients.initialize();
+  // Import RemoteSettings for ``pollChanges()``
+  return ChromeUtils.import("resource://services-common/remote-settings.js", {});
+});
 
 const TOOLKIT_ID                      = "toolkit@mozilla.org";
 const KEY_PROFILEDIR                  = "ProfD";
 const KEY_APPDIR                      = "XCurProcD";
 const FILE_BLOCKLIST                  = "blocklist.xml";
 const PREF_BLOCKLIST_LASTUPDATETIME   = "app.update.lastUpdateTime.blocklist-background-update-timer";
 const PREF_BLOCKLIST_URL              = "extensions.blocklist.url";
 const PREF_BLOCKLIST_ITEM_URL         = "extensions.blocklist.itemURL";
@@ -565,17 +569,17 @@ Blocklist.prototype = {
     // When the blocklist loads we need to compare it to the current copy so
     // make sure we have loaded it.
     if (!this.isLoaded)
       this._loadBlocklist();
 
     // If blocklist update via Kinto is enabled, poll for changes and sync.
     // Currently certificates blocklist relies on it by default.
     if (Services.prefs.getBoolPref(PREF_BLOCKLIST_UPDATE_ENABLED)) {
-      BlocklistUpdater.checkVersions().catch(() => {
+      RemoteSettings.pollChanges().catch(() => {
         // Bug 1254099 - Telemetry (success or errors) will be collected during this process.
       });
     }
   },
 
   async onXMLLoad(aEvent) {
     let request = aEvent.target;
     try {