Bug 935232 - Implement a client for the Firefox Accounts auth server. r=markh

This commit is contained in:
Zachary Carter 2013-12-02 13:56:24 -08:00
parent 896c4bea7f
commit 09185a9092
13 changed files with 682 additions and 5 deletions

View File

@ -847,3 +847,6 @@ pref("media.webspeech.synth.enabled", true);
// Downloads API
pref("dom.mozDownloads.enabled", true);
pref("dom.downloads.max_retention_days", 7);
// The URL of the Firefox Accounts auth server backend
pref("identity.fxaccounts.auth.uri", "https://api-accounts.dev.lcip.org/v1");

View File

@ -1335,3 +1335,6 @@ pref("network.disable.ipc.security", true);
// CustomizableUI debug logging.
pref("browser.uiCustomization.debug", false);
// The URL of the Firefox Accounts auth server backend
pref("identity.fxaccounts.auth.uri", "https://api-accounts.dev.lcip.org/v1");

View File

@ -35,6 +35,26 @@ function do_check_throws(aFunc, aResult, aStack) {
do_throw("Expected result " + aResult + ", none thrown.", aStack);
}
/**
* Test whether specified function throws exception with expected
* result.
*
* @param func
* Function to be tested.
* @param message
* Message of expected exception. <code>null</code> for no throws.
*/
function do_check_throws_message(aFunc, aResult) {
try {
aFunc();
} catch (e) {
do_check_eq(e.message, aResult);
return;
}
do_throw("Expected an error, none thrown.");
}
/**
* Print some debug message to the console. All arguments will be printed,
* separated by spaces.

View File

@ -204,6 +204,14 @@ this.CommonUtils = {
return hex;
},
hexToBytes: function hexToBytes(str) {
let bytes = [];
for (let i = 0; i < str.length - 1; i += 2) {
bytes.push(parseInt(str.substr(i, 2), 16));
}
return String.fromCharCode.apply(String, bytes);
},
/**
* Base32 encode (RFC 4648) a string
*/

View File

@ -11,6 +11,20 @@ Cu.import("resource://services-common/utils.js");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
this.CryptoUtils = {
xor: function xor(a, b) {
let bytes = [];
if (a.length != b.length) {
throw new Error("can't xor unequal length strings: "+a.length+" vs "+b.length);
}
for (let i = 0; i < a.length; i++) {
bytes[i] = a.charCodeAt(i) ^ b.charCodeAt(i);
}
return String.fromCharCode.apply(String, bytes);
},
/**
* Generate a string of random bytes.
*/
@ -109,6 +123,22 @@ this.CryptoUtils = {
return hasher;
},
/**
* HMAC-based Key Derivation (RFC 5869).
*/
hkdf: function hkdf(ikm, xts, info, len) {
const BLOCKSIZE = 256 / 8;
if (typeof xts === undefined)
xts = String.fromCharCode(0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0);
let h = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256,
CryptoUtils.makeHMACKey(xts));
let prk = CryptoUtils.digestBytes(ikm, h);
return CryptoUtils.hkdfExpand(prk, info, len);
},
/**
* HMAC-based Key Derivation Step 2 according to RFC 5869.
*/
@ -458,9 +488,8 @@ this.CryptoUtils = {
let contentType = CryptoUtils.stripHeaderAttributes(options.contentType);
if (!artifacts.hash &&
options.hasOwnProperty("payload") &&
options.payload) {
if (!artifacts.hash && options.hasOwnProperty("payload")
&& options.payload) {
let hasher = Cc["@mozilla.org/security/hash;1"]
.createInstance(Ci.nsICryptoHash);
hasher.init(hash_algo);
@ -469,8 +498,9 @@ this.CryptoUtils = {
CryptoUtils.updateUTF8(options.payload, hasher);
CryptoUtils.updateUTF8("\n", hasher);
let hash = hasher.finish(false);
// HAWK specifies this .hash to include trailing "==" padding.
let hash_b64 = CommonUtils.encodeBase64URL(hash, true);
// HAWK specifies this .hash to use +/ (not _-) and include the
// trailing "==" padding.
let hash_b64 = btoa(hash);
artifacts.hash = hash_b64;
}

View File

@ -93,16 +93,28 @@ function expand_hex(prk, info, len) {
return CommonUtils.bytesAsHex(CryptoUtils.hkdfExpand(prk, info, len));
}
function hkdf_hex(ikm, salt, info, len) {
ikm = _hexToString(ikm);
if (salt)
salt = _hexToString(salt);
info = _hexToString(info);
return CommonUtils.bytesAsHex(CryptoUtils.hkdf(ikm, salt, info, len));
}
function run_test() {
_("Verifying Test Case 1");
do_check_eq(extract_hex(tc1.salt, tc1.IKM), tc1.PRK);
do_check_eq(expand_hex(tc1.PRK, tc1.info, tc1.L), tc1.OKM);
do_check_eq(hkdf_hex(tc1.IKM, tc1.salt, tc1.info, tc1.L), tc1.OKM);
_("Verifying Test Case 2");
do_check_eq(extract_hex(tc2.salt, tc2.IKM), tc2.PRK);
do_check_eq(expand_hex(tc2.PRK, tc2.info, tc2.L), tc2.OKM);
do_check_eq(hkdf_hex(tc2.IKM, tc2.salt, tc2.info, tc2.L), tc2.OKM);
_("Verifying Test Case 3");
do_check_eq(extract_hex(tc3.salt, tc3.IKM), tc3.PRK);
do_check_eq(expand_hex(tc3.PRK, tc3.info, tc3.L), tc3.OKM);
do_check_eq(hkdf_hex(tc3.IKM, tc3.salt, tc3.info, tc3.L), tc3.OKM);
do_check_eq(hkdf_hex(tc3.IKM, undefined, tc3.info, tc3.L), tc3.OKM);
}

View File

@ -0,0 +1,330 @@
/* 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/. */
this.EXPORTED_SYMBOLS = ["FxAccountsClient"];
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://services-common/utils.js");
Cu.import("resource://services-crypto/utils.js");
// Default can be changed by the preference 'identity.fxaccounts.auth.uri'
let _host = "https://api-accounts.dev.lcip.org/v1";
try {
_host = Services.prefs.getCharPref("identity.fxaccounts.auth.uri");
} catch(keepDefault) {}
const HOST = _host;
const PREFIX_NAME = "identity.mozilla.com/picl/v1/";
const XMLHttpRequest =
Components.Constructor("@mozilla.org/xmlextras/xmlhttprequest;1");
function stringToHex(str) {
let encoder = new TextEncoder("utf-8");
let bytes = encoder.encode(str);
return bytesToHex(bytes);
}
// XXX Sadly, CommonUtils.bytesAsHex doesn't handle typed arrays.
function bytesToHex(bytes) {
let hex = [];
for (let i = 0; i < bytes.length; i++) {
hex.push((bytes[i] >>> 4).toString(16));
hex.push((bytes[i] & 0xF).toString(16));
}
return hex.join("");
}
this.FxAccountsClient = function(host = HOST) {
this.host = host;
};
this.FxAccountsClient.prototype = {
/**
* Create a new Firefox Account and authenticate
*
* @param email
* The email address for the account (utf8)
* @param password
* The user's password
* @return Promise
* Returns a promise that resolves to an object:
* {
* uid: the user's unique ID
* sessionToken: a session token
* }
*/
signUp: function (email, password) {
let uid;
let hexEmail = stringToHex(email);
let uidPromise = this._request("/raw_password/account/create", "POST", null,
{email: hexEmail, password: password});
return uidPromise.then((result) => {
uid = result.uid;
return this.signIn(email, password)
.then(function(result) {
result.uid = uid;
return result;
});
});
},
/**
* Authenticate and create a new session with the Firefox Account API server
*
* @param email
* The email address for the account (utf8)
* @param password
* The user's password
* @return Promise
* Returns a promise that resolves to an object:
* {
* uid: the user's unique ID
* sessionToken: a session token
* isVerified: flag indicating verification status of the email
* }
*/
signIn: function signIn(email, password) {
let hexEmail = stringToHex(email);
return this._request("/raw_password/session/create", "POST", null,
{email: hexEmail, password: password});
},
/**
* Destroy the current session with the Firefox Account API server
*
* @param sessionTokenHex
* The session token endcoded in hex
* @return Promise
*/
signOut: function (sessionTokenHex) {
return this._request("/session/destroy", "POST",
this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
},
/**
* Check the verification status of the user's FxA email address
*
* @param sessionTokenHex
* The current session token endcoded in hex
* @return Promise
*/
recoveryEmailStatus: function (sessionTokenHex) {
return this._request("/recovery_email/status", "GET",
this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
},
/**
* Retrieve encryption keys
*
* @param keyFetchTokenHex
* A one-time use key fetch token encoded in hex
* @return Promise
* Returns a promise that resolves to an object:
* {
* kA: an encryption key for recevorable data
* wrapKB: an encryption key that requires knowledge of the user's password
* }
*/
accountKeys: function (keyFetchTokenHex) {
let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
let keyRequestKey = creds.extra.slice(0, 32);
let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined,
PREFIX_NAME + "account/keys", 3 * 32);
let respHMACKey = morecreds.slice(0, 32);
let respXORKey = morecreds.slice(32, 96);
return this._request("/account/keys", "GET", creds).then(resp => {
if (!resp.bundle) {
throw new Error("failed to retrieve keys");
}
let bundle = CommonUtils.hexToBytes(resp.bundle);
let mac = bundle.slice(-32);
let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256,
CryptoUtils.makeHMACKey(respHMACKey));
let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher);
if (mac !== bundleMAC) {
throw new Error("error unbundling encryption keys");
}
let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
return {
kA: keyAWrapB.slice(0, 32),
wrapKB: keyAWrapB.slice(32)
};
});
},
/**
* Sends a public key to the FxA API server and returns a signed certificate
*
* @param sessionTokenHex
* The current session token endcoded in hex
* @param serializedPublicKey
* A public key (usually generated by jwcrypto)
* @param lifetime
* The lifetime of the certificate
* @return Promise
* Returns a promise that resolves to the signed certificate. The certificate
* can be used to generate a Persona assertion.
*/
signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) {
let creds = this._deriveHawkCredentials(sessionTokenHex, "sessionToken");
let body = { publicKey: serializedPublicKey,
duration: lifetime };
return Promise.resolve()
.then(_ => this._request("/certificate/sign", "POST", creds, body))
.then(resp => resp.cert,
err => {dump("HAWK.signCertificate error: " + err + "\n");
throw err;});
},
/**
* Determine if an account exists
*
* @param email
* The email address to check
* @return Promise
* The promise resolves to true if the account exists, or false
* if it doesn't. The promise is rejected on other errors.
*/
accountExists: function (email) {
let hexEmail = stringToHex(email);
return this._request("/auth/start", "POST", null, { email: hexEmail })
.then(
// the account exists
(result) => true,
(err) => {
// the account doesn't exist
if (err.errno === 102) {
return false;
}
// propogate other request errors
throw err;
}
);
},
/**
* The FxA auth server expects requests to certain endpoints to be authorized using Hawk.
* Hawk credentials are derived using shared secrets, which depend on the context
* (e.g. sessionToken vs. keyFetchToken).
*
* @param tokenHex
* The current session token endcoded in hex
* @param context
* A context for the credentials
* @param size
* The size in bytes of the expected derived buffer
* @return credentials
* Returns an object:
* {
* algorithm: sha256
* id: the Hawk id (from the first 32 bytes derived)
* key: the Hawk key (from bytes 32 to 64)
* extra: size - 64 extra bytes
* }
*/
_deriveHawkCredentials: function (tokenHex, context, size) {
let token = CommonUtils.hexToBytes(tokenHex);
let out = CryptoUtils.hkdf(token, undefined, PREFIX_NAME + context, size || 3 * 32);
return {
algorithm: "sha256",
key: out.slice(32, 64),
extra: out.slice(64),
id: CommonUtils.bytesAsHex(out.slice(0, 32))
};
},
/**
* A general method for sending raw API calls to the FxA auth server.
* All request bodies and responses are JSON.
*
* @param path
* API endpoint path
* @param method
* The HTTP request method
* @param credentials
* Hawk credentials
* @param jsonPayload
* A JSON payload
* @return Promise
* Returns a promise that resolves to the JSON response of the API call,
* or is rejected with an error. Error responses have the following properties:
* {
* "code": 400, // matches the HTTP status code
* "errno": 107, // stable application-level error number
* "error": "Bad Request", // string description of the error type
* "message": "the value of salt is not allowed to be undefined",
* "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
* }
*/
_request: function hawkRequest(path, method, credentials, jsonPayload) {
let deferred = Promise.defer();
let xhr = new XMLHttpRequest({mozSystem: true});
let URI = this.host + path;
let payload;
xhr.mozBackgroundRequest = true;
if (jsonPayload) {
payload = JSON.stringify(jsonPayload);
}
xhr.open(method, URI);
xhr.channel.loadFlags = Ci.nsIChannel.LOAD_BYPASS_CACHE |
Ci.nsIChannel.INHIBIT_CACHING;
// When things really blow up, reconstruct an error object that follows the general format
// of the server on error responses.
function constructError(err) {
return { error: err, message: xhr.statusText, code: xhr.status, errno: xhr.status };
}
xhr.onerror = function() {
deferred.reject(constructError('Request failed'));
};
xhr.onload = function onload() {
try {
let response = JSON.parse(xhr.responseText);
if (xhr.status !== 200 || response.error) {
// In this case, the response is an object with error information.
return deferred.reject(response);
}
deferred.resolve(response);
} catch (e) {
deferred.reject(constructError(e));
}
};
let uri = Services.io.newURI(URI, null, null);
if (credentials) {
let header = CryptoUtils.computeHAWK(uri, method, {
credentials: credentials,
payload: payload,
contentType: "application/json"
});
xhr.setRequestHeader("authorization", header.field);
}
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(payload);
return deferred.promise;
},
};

View File

@ -0,0 +1,8 @@
# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# 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/.
TEST_DIRS += ['tests']
EXTRA_JS_MODULES += ['FxAccountsClient.jsm']

View File

@ -0,0 +1,7 @@
# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# 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/.
XPCSHELL_TESTS_MANIFESTS += ['xpcshell/xpcshell.ini']

View File

@ -0,0 +1,18 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
"use strict";
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
(function initFxAccountsTestingInfrastructure() {
do_get_profile();
let ns = {};
Cu.import("resource://testing-common/services-common/logging.js", ns);
ns.initTestLogging("Trace");
}).call(this);

View File

@ -0,0 +1,231 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
Cu.import("resource://gre/modules/FxAccountsClient.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://services-common/utils.js");
function run_test() {
run_next_test();
}
function deferredStop(server) {
let deferred = Promise.defer();
server.stop(deferred.resolve);
return deferred.promise;
}
add_test(function test_hawk_credentials() {
let client = new FxAccountsClient();
let sessionToken = "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
let result = client._deriveHawkCredentials(sessionToken, "session");
do_check_eq(result.id, "639503a218ffbb62983e9628be5cd64a0438d0ae81b2b9dadeb900a83470bc6b");
do_check_eq(CommonUtils.bytesAsHex(result.key), "3a0188943837ab228fe74e759566d0e4837cbcc7494157aac4da82025b2811b2");
run_next_test();
});
add_task(function test_authenticated_get_request() {
let message = "{\"msg\": \"Great Success!\"}";
let credentials = {
id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
algorithm: "sha256"
};
let method = "GET";
let server = httpd_setup({"/foo": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(message, message.length);
}
});
let client = new FxAccountsClient(server.baseURI);
let result = yield client._request("/foo", method, credentials);
do_check_eq("Great Success!", result.msg);
yield deferredStop(server);
});
add_task(function test_authenticated_post_request() {
let credentials = {
id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
algorithm: "sha256"
};
let method = "POST";
let server = httpd_setup({"/foo": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
response.setStatusLine(request.httpVersion, 200, "OK");
response.setHeader("Content-Type", "application/json");
response.bodyOutputStream.writeFrom(request.bodyInputStream, request.bodyInputStream.available());
}
});
let client = new FxAccountsClient(server.baseURI);
let result = yield client._request("/foo", method, credentials, {foo: "bar"});
do_check_eq("bar", result.foo);
yield deferredStop(server);
});
add_task(function test_500_error() {
let message = "<h1>Ooops!</h1>";
let method = "GET";
let server = httpd_setup({"/foo": function(request, response) {
response.setStatusLine(request.httpVersion, 500, "Internal Server Error");
response.bodyOutputStream.write(message, message.length);
}
});
let client = new FxAccountsClient(server.baseURI);
try {
yield client._request("/foo", method);
} catch (e) {
do_check_eq(500, e.code);
do_check_eq("Internal Server Error", e.message);
}
yield deferredStop(server);
});
add_task(function test_api_endpoints() {
let sessionMessage = JSON.stringify({sessionToken: "NotARealToken"});
let creationMessage = JSON.stringify({uid: "NotARealUid"});
let signoutMessage = JSON.stringify({});
let certSignMessage = JSON.stringify({cert: {bar: "baz"}});
let emailStatus = JSON.stringify({verified: true});
let authStarts = 0;
function writeResp(response, msg) {
response.bodyOutputStream.write(msg, msg.length);
}
let server = httpd_setup(
{
"/raw_password/account/create": function(request, response) {
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let jsonBody = JSON.parse(body);
do_check_eq(jsonBody.email, "796f75406578616d706c652e636f6d");
do_check_eq(jsonBody.password, "biggersecret");
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(creationMessage, creationMessage.length);
},
"/raw_password/session/create": function(request, response) {
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let jsonBody = JSON.parse(body);
if (jsonBody.password === "bigsecret") {
do_check_eq(jsonBody.email, "6dc3a9406578616d706c652e636f6d");
} else if (jsonBody.password === "biggersecret") {
do_check_eq(jsonBody.email, "796f75406578616d706c652e636f6d");
}
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(sessionMessage, sessionMessage.length);
},
"/recovery_email/status": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(emailStatus, emailStatus.length);
},
"/session/destroy": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(signoutMessage, signoutMessage.length);
},
"/certificate/sign": function(request, response) {
do_check_true(request.hasHeader("Authorization"));
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
let jsonBody = JSON.parse(body);
do_check_eq(JSON.parse(jsonBody.publicKey).foo, "bar");
do_check_eq(jsonBody.duration, 600);
response.setStatusLine(request.httpVersion, 200, "OK");
response.bodyOutputStream.write(certSignMessage, certSignMessage.length);
},
"/auth/start": function(request, response) {
if (authStarts === 0) {
response.setStatusLine(request.httpVersion, 200, "OK");
writeResp(response, JSON.stringify({}));
} else if (authStarts === 1) {
response.setStatusLine(request.httpVersion, 400, "NOT OK");
writeResp(response, JSON.stringify({errno: 102, error: "no such account"}));
} else if (authStarts === 2) {
response.setStatusLine(request.httpVersion, 400, "NOT OK");
writeResp(response, JSON.stringify({errno: 107, error: "boom"}));
}
authStarts++;
},
}
);
let client = new FxAccountsClient(server.baseURI);
let result = undefined;
result = yield client.signUp('you@example.com', 'biggersecret');
do_check_eq("NotARealUid", result.uid);
result = yield client.signIn('mé@example.com', 'bigsecret');
do_check_eq("NotARealToken", result.sessionToken);
result = yield client.signOut('NotARealToken');
do_check_eq(typeof result, "object");
result = yield client.recoveryEmailStatus('NotARealToken');
do_check_eq(result.verified, true);
result = yield client.signCertificate('NotARealToken', JSON.stringify({foo: "bar"}), 600);
do_check_eq("baz", result.bar);
result = yield client.accountExists('hey@example.com');
do_check_eq(result, true);
result = yield client.accountExists('hey2@example.com');
do_check_eq(result, false);
try {
result = yield client.accountExists('hey3@example.com');
} catch(e) {
do_check_eq(e.errno, 107);
}
yield deferredStop(server);
});
add_task(function test_error_response() {
let errorMessage = JSON.stringify({error: "Oops", code: 400, errno: 99});
let server = httpd_setup(
{
"/raw_password/session/create": function(request, response) {
let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
response.setStatusLine(request.httpVersion, 400, "NOT OK");
response.bodyOutputStream.write(errorMessage, errorMessage.length);
},
}
);
let client = new FxAccountsClient(server.baseURI);
try {
let result = yield client.signIn('mé@example.com', 'bigsecret');
} catch(result) {
do_check_eq("Oops", result.error);
do_check_eq(400, result.code);
do_check_eq(99, result.errno);
}
yield deferredStop(server);
});

View File

@ -0,0 +1,6 @@
[DEFAULT]
head = head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js
tail =
[test_client.js]

View File

@ -7,6 +7,7 @@
PARALLEL_DIRS += [
'common',
'crypto',
'fxaccounts',
]
if CONFIG['MOZ_WIDGET_TOOLKIT'] != 'android':