Files
tubestation/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
2017-04-27 12:30:07 -07:00

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}`);
},
};