--- 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)
);
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() { });
+ });
+}
+
+