150 lines
4.4 KiB
JavaScript
150 lines
4.4 KiB
JavaScript
/* 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";
|
|
|
|
const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
|
|
Cu.import("resource://gre/modules/Services.jsm");
|
|
Cu.import("resource://gre/modules/CanonicalJSON.jsm");
|
|
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
|
|
Cu.import("resource://shield-recipe-client/lib/Utils.jsm");
|
|
Cu.importGlobalProperties(["fetch", "URL"]); /* globals fetch, URL */
|
|
|
|
this.EXPORTED_SYMBOLS = ["NormandyApi"];
|
|
|
|
const log = LogManager.getLogger("normandy-api");
|
|
const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
|
|
|
|
let indexPromise = null;
|
|
|
|
this.NormandyApi = {
|
|
clearIndexCache() {
|
|
indexPromise = null;
|
|
},
|
|
|
|
apiCall(method, endpoint, data = {}) {
|
|
const url = new URL(endpoint);
|
|
method = method.toLowerCase();
|
|
|
|
let body = undefined;
|
|
if (data) {
|
|
if (method === "get") {
|
|
for (const key of Object.keys(data)) {
|
|
url.searchParams.set(key, data[key]);
|
|
}
|
|
} else if (method === "post") {
|
|
body = JSON.stringify(data);
|
|
}
|
|
}
|
|
|
|
const headers = {"Accept": "application/json"};
|
|
return fetch(url.href, {method, body, headers});
|
|
},
|
|
|
|
get(endpoint, data) {
|
|
return this.apiCall("get", endpoint, data);
|
|
},
|
|
|
|
post(endpoint, data) {
|
|
return this.apiCall("post", endpoint, data);
|
|
},
|
|
|
|
absolutify(url) {
|
|
const apiBase = prefs.getCharPref("api_url");
|
|
const server = new URL(apiBase).origin;
|
|
if (url.startsWith("http")) {
|
|
return url;
|
|
} else if (url.startsWith("/")) {
|
|
return server + url;
|
|
}
|
|
throw new Error("Can't use relative urls");
|
|
},
|
|
|
|
async getApiUrl(name) {
|
|
const apiBase = prefs.getCharPref("api_url");
|
|
if (!indexPromise) {
|
|
indexPromise = this.get(apiBase).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);
|
|
},
|
|
|
|
async fetchRecipes(filters = {enabled: true}) {
|
|
const signedRecipesUrl = await this.getApiUrl("recipe-signed");
|
|
const recipesResponse = await this.get(signedRecipesUrl, filters);
|
|
const rawText = await recipesResponse.text();
|
|
const recipesWithSigs = JSON.parse(rawText);
|
|
|
|
const verifiedRecipes = [];
|
|
|
|
for (const {recipe, signature: {signature, x5u}} of recipesWithSigs) {
|
|
const serialized = CanonicalJSON.stringify(recipe);
|
|
if (!rawText.includes(serialized)) {
|
|
log.debug(rawText, serialized);
|
|
throw new Error("Canonical recipe serialization does not match!");
|
|
}
|
|
|
|
const certChainResponse = await fetch(this.absolutify(x5u));
|
|
const certChain = await certChainResponse.text();
|
|
const builtSignature = `p384ecdsa=${signature}`;
|
|
|
|
const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
|
|
.createInstance(Ci.nsIContentSignatureVerifier);
|
|
|
|
const valid = verifier.verifyContentSignature(
|
|
serialized,
|
|
builtSignature,
|
|
certChain,
|
|
"normandy.content-signature.mozilla.org"
|
|
);
|
|
if (!valid) {
|
|
throw new Error("Recipe signature is not valid");
|
|
}
|
|
verifiedRecipes.push(recipe);
|
|
}
|
|
|
|
log.debug(
|
|
`Fetched ${verifiedRecipes.length} recipes from the server:`,
|
|
verifiedRecipes.map(r => r.name).join(", ")
|
|
);
|
|
|
|
return verifiedRecipes;
|
|
},
|
|
|
|
/**
|
|
* 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 an array of available actions from the server.
|
|
* @resolves {Array}
|
|
*/
|
|
async fetchActions() {
|
|
const actionApiUrl = await this.getApiUrl("action-list");
|
|
const res = await this.get(actionApiUrl);
|
|
return await res.json();
|
|
},
|
|
|
|
async fetchImplementation(action) {
|
|
const response = await fetch(action.implementation_url);
|
|
if (response.ok) {
|
|
return await response.text();
|
|
}
|
|
|
|
throw new Error(`Failed to fetch action implementation for ${action.name}: ${response.status}`);
|
|
},
|
|
};
|