diff options
Diffstat (limited to 'services/common/tests/unit/test_blocklist_signatures.js')
-rw-r--r-- | services/common/tests/unit/test_blocklist_signatures.js | 510 |
1 files changed, 510 insertions, 0 deletions
diff --git a/services/common/tests/unit/test_blocklist_signatures.js b/services/common/tests/unit/test_blocklist_signatures.js new file mode 100644 index 000000000..b2ee1019a --- /dev/null +++ b/services/common/tests/unit/test_blocklist_signatures.js @@ -0,0 +1,510 @@ +"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"); +} + +function* checkRecordCount(count) { + // 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(count, records.data.length); + + // Close the collection so the test can exit cleanly + yield collection.db.close(); +} + +// 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, + "vxuAg5rDCB-1pul4a91vqSBQRXJG_j7WOYUTswxRSMltdYmbhLRH8R8brQ9YKuNDF56F-w6pn4HWxb076qgKPwgcEBtUeZAO_RtaHXRkRUUgVzAr86yQL4-aJTbv3D6u"); + + // 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, + "dwhJeypadNIyzGj3QdI0KMRTPnHhFPF_j73mNrsPAHKMW46S2Ftf4BzsPMvPMB8h0TjDus13wo_R4l432DHe7tYyMIWXY0PBeMcoe5BREhFIxMxTsh9eGVXBD1e3UwRy"); + + // 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, + "MIEmNghKnkz12UodAAIc3q_Y4a3IJJ7GhHF4JYNYmm8avAGyPM9fYU7NzVo94pzjotG7vmtiYuHyIX2rTHTbT587w0LdRWxipgFd_PC1mHiwUyjFYNqBBG-kifYk7kEw"); + + // 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 signature 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 badSigGoodOldResponses = { + // 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_EMPTY_SIG], + // The first collection state is the current state (since there's no update + // - 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 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 + yield checkRecordCount(2); + + registerHandlers(badSigGoodOldResponses); + 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) { + yield checkRecordCount(2); + } +}); + +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() { }); + }); +} + + |