/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CanonicalJSON: "resource://gre/modules/CanonicalJSON.sys.mjs", }); const prefs = Services.prefs.getBranch("app.normandy."); let indexPromise = null; function getChainRootIdentifier() { const normandy_url = Services.prefs.getCharPref("app.normandy.api_url"); if (normandy_url == "https://normandy.cdn.mozilla.net/api/v1") { return Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot; } if (normandy_url.includes("stage.")) { return Ci.nsIContentSignatureVerifier.ContentSignatureStageRoot; } if (normandy_url.includes("dev.")) { return Ci.nsIContentSignatureVerifier.ContentSignatureDevRoot; } if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { return Ci.nsIX509CertDB.AppXPCShellRoot; } return Ci.nsIContentSignatureVerifier.ContentSignatureLocalRoot; } export var NormandyApi = { InvalidSignatureError: class InvalidSignatureError extends Error {}, clearIndexCache() { indexPromise = null; }, get(endpoint, data) { const url = new URL(endpoint); if (data) { for (const key of Object.keys(data)) { url.searchParams.set(key, data[key]); } } return fetch(url.href, { method: "get", headers: { Accept: "application/json" }, credentials: "omit", }); }, absolutify(url) { if (url.startsWith("http")) { return url; } const apiBase = prefs.getCharPref("api_url"); const server = new URL(apiBase).origin; if (url.startsWith("/")) { return server + url; } throw new Error("Can't use relative urls"); }, async getApiUrl(name) { if (!indexPromise) { const apiBase = new URL(prefs.getCharPref("api_url")); if (!apiBase.pathname.endsWith("/")) { apiBase.pathname += "/"; } indexPromise = this.get(apiBase.toString()).then(res => res.json()); } const index = await indexPromise; if (!(name in index)) { throw new Error(`API endpoint with name "${name}" not found.`); } const url = index[name]; return this.absolutify(url); }, /** * Verify content signature, by serializing the specified `object` as * canonical JSON, and using the Normandy signer verifier to check that * it matches the signature specified in `signaturePayload`. * * If the the signature is not valid, an error is thrown. Otherwise this * function returns undefined. * * @param {object|String} data The object (or string) to be checked * @param {object} signaturePayload The signature information * @param {String} signaturePayload.x5u The certificate chain URL * @param {String} signaturePayload.signature base64 signature bytes * @param {String} type The object type (eg. `"recipe"`, `"action"`) * @returns {Promise} If the signature is valid, this function returns without error * @throws {NormandyApi.InvalidSignatureError} if signature is invalid. */ async verifyObjectSignature(data, signaturePayload, type) { const { signature, x5u } = signaturePayload; const certChainResponse = await this.get(this.absolutify(x5u)); const certChain = await certChainResponse.text(); const builtSignature = `p384ecdsa=${signature}`; const serialized = typeof data == "string" ? data : lazy.CanonicalJSON.stringify(data); const verifier = Cc[ "@mozilla.org/security/contentsignatureverifier;1" ].createInstance(Ci.nsIContentSignatureVerifier); let valid; try { valid = await verifier.asyncVerifyContentSignature( serialized, builtSignature, certChain, "normandy.content-signature.mozilla.org", getChainRootIdentifier() ); } catch (err) { throw new NormandyApi.InvalidSignatureError( `${type} signature validation failed: ${err}` ); } if (!valid) { throw new NormandyApi.InvalidSignatureError( `${type} signature is not valid` ); } }, /** * Fetch metadata about this client determined by the server. * @return {object} Metadata specified by the server */ async classifyClient() { const classifyClientUrl = await this.getApiUrl("classify-client"); const response = await this.get(classifyClientUrl); const clientData = await response.json(); clientData.request_time = new Date(clientData.request_time); return clientData; }, /** * Fetch details for an extension from the server. * @param extensionId {integer} The ID of the extension to look up * @resolves {Object} */ async fetchExtensionDetails(extensionId) { const baseUrl = await this.getApiUrl("extension-list"); const extensionDetailsUrl = `${baseUrl}${extensionId}/`; const response = await this.get(extensionDetailsUrl); return response.json(); }, };