commit f939b6229b83e880e4c638318f173acf7d49b0cc
parent 872ffc3efabb753b8043cedba568d6dbde7ca81a
Author: Cecylia Bocovich <cohosh@torproject.org>
Date: Tue, 19 Dec 2023 17:26:26 -0500
Lox integration
Diffstat:
6 files changed, 1164 insertions(+), 0 deletions(-)
diff --git a/browser/app/profile/000-tor-browser.js b/browser/app/profile/000-tor-browser.js
@@ -133,3 +133,4 @@ pref("extensions.torlauncher.moat_service", "https://bridges.torproject.org/moat
// Log levels
pref("browser.tor_provider.log_level", "Warn");
pref("browser.tor_provider.cp_log_level", "Warn");
+pref("lox.log_level", "Warn");
diff --git a/dom/security/nsContentSecurityUtils.cpp b/dom/security/nsContentSecurityUtils.cpp
@@ -641,6 +641,9 @@ bool nsContentSecurityUtils::IsEvalAllowed(JSContext* cx,
// The Browser Toolbox/Console
"debugger"_ns,
+
+ // Tor Browser's Lox wasm integration
+ "resource://gre/modules/lox_wasm.jsm"_ns,
};
// We also permit two specific idioms in eval()-like contexts. We'd like to
diff --git a/toolkit/components/lox/Lox.sys.mjs b/toolkit/components/lox/Lox.sys.mjs
@@ -0,0 +1,1148 @@
+/* eslint-disable mozilla/valid-lazy */
+import {
+ clearInterval,
+ setInterval,
+} from "resource://gre/modules/Timer.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => {
+ return console.createInstance({
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "lox.log_level",
+ prefix: "Lox",
+ });
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DomainFrontRequestBuilder:
+ "resource://gre/modules/DomainFrontedRequests.sys.mjs",
+ DomainFrontRequestNetworkError:
+ "resource://gre/modules/DomainFrontedRequests.sys.mjs",
+ DomainFrontRequestResponseError:
+ "resource://gre/modules/DomainFrontedRequests.sys.mjs",
+ TorConnect: "resource://gre/modules/TorConnect.sys.mjs",
+ TorConnectStage: "resource://gre/modules/TorConnect.sys.mjs",
+ TorSettings: "resource://gre/modules/TorSettings.sys.mjs",
+ TorSettingsTopics: "resource://gre/modules/TorSettings.sys.mjs",
+ TorBridgeSource: "resource://gre/modules/TorSettings.sys.mjs",
+});
+
+// XPCOMUtils.defineLazyModuleGetters(lazy, {
+// init: "resource://gre/modules/lox_wasm.jsm",
+// open_invite: "resource://gre/modules/lox_wasm.jsm",
+// handle_new_lox_credential: "resource://gre/modules/lox_wasm.jsm",
+// set_panic_hook: "resource://gre/modules/lox_wasm.jsm",
+// invitation_is_trusted: "resource://gre/modules/lox_wasm.jsm",
+// issue_invite: "resource://gre/modules/lox_wasm.jsm",
+// handle_issue_invite: "resource://gre/modules/lox_wasm.jsm",
+// prepare_invite: "resource://gre/modules/lox_wasm.jsm",
+// get_invites_remaining: "resource://gre/modules/lox_wasm.jsm",
+// get_trust_level: "resource://gre/modules/lox_wasm.jsm",
+// level_up: "resource://gre/modules/lox_wasm.jsm",
+// handle_level_up: "resource://gre/modules/lox_wasm.jsm",
+// trust_promotion: "resource://gre/modules/lox_wasm.jsm",
+// handle_trust_promotion: "resource://gre/modules/lox_wasm.jsm",
+// trust_migration: "resource://gre/modules/lox_wasm.jsm",
+// handle_trust_migration: "resource://gre/modules/lox_wasm.jsm",
+// get_next_unlock: "resource://gre/modules/lox_wasm.jsm",
+// check_blockage: "resource://gre/modules/lox_wasm.jsm",
+// handle_check_blockage: "resource://gre/modules/lox_wasm.jsm",
+// blockage_migration: "resource://gre/modules/lox_wasm.jsm",
+// handle_blockage_migration: "resource://gre/modules/lox_wasm.jsm",
+// check_lox_pubkeys_update: "resource://gre/modules/lox_wasm.jsm",
+// handle_update_cred: "resource://gre/modules/lox_wasm.jsm",
+// });
+
+export const LoxTopics = Object.freeze({
+ // Whenever the activeLoxId value changes.
+ UpdateActiveLoxId: "lox:update-active-lox-id",
+ // Whenever the bridges *might* have changed.
+ // getBridges only uses #credentials, so this will only fire when it changes.
+ UpdateBridges: "lox:update-bridges",
+ // Whenever we gain a new upgrade or blockage event, or clear events.
+ UpdateEvents: "lox:update-events",
+ // Whenever the next unlock *might* have changed.
+ // getNextUnlock uses #credentials and #constants, so will fire when either
+ // value changes.
+ UpdateNextUnlock: "lox:update-next-unlock",
+ // Whenever the remaining invites *might* have changed.
+ // getRemainingInviteCount only uses #credentials, so will only fire when it
+ // changes.
+ UpdateRemainingInvites: "lox:update-remaining-invites",
+ // Whenever we generate a new invite.
+ NewInvite: "lox:new-invite",
+});
+
+const LoxSettingsPrefs = Object.freeze({
+ /* string: the lox credential */
+ credentials: "lox.settings.credentials",
+ invites: "lox.settings.invites",
+ events: "lox.settings.events",
+ pubkeys: "lox.settings.pubkeys",
+ enctable: "lox.settings.enctable",
+ constants: "lox.settings.constants",
+});
+
+/**
+ * Error class for Lox.
+ */
+export class LoxError extends Error {
+ static BadInvite = "BadInvite";
+ static LoxServerUnreachable = "LoxServerUnreachable";
+ static ErrorResponse = "ErrorResponse";
+
+ /**
+ * @param {string} message - The error message.
+ * @param {string?} [code] - The specific error type, if any.
+ */
+ constructor(message, code = null) {
+ super(message);
+ this.name = "LoxError";
+ this.code = code;
+ }
+}
+
+/**
+ * This class contains the implementation of the Lox client.
+ * It provides data for the frontend and it runs background operations.
+ */
+class LoxImpl {
+ /**
+ * Whether the Lox module has completed initialization.
+ *
+ * @type {boolean}
+ */
+ #initialized = false;
+
+ /**
+ * Whether the Lox module is enabled for this Tor Browser instance.
+ *
+ * @type {boolean}
+ */
+ #enabled = false;
+
+ get enabled() {
+ return this.#enabled;
+ }
+
+ #pubKeyPromise = null;
+ #encTablePromise = null;
+ #constantsPromise = null;
+ #domainFrontedRequests = null;
+ /**
+ * The list of invites generated.
+ *
+ * @type {string[]}
+ */
+ #invites = [];
+ #pubKeys = null;
+ #encTable = null;
+ #constants = null;
+ /**
+ * The latest credentials for a given lox id.
+ *
+ * @type {Map<string, string>}
+ */
+ #credentials = new Map();
+ /**
+ * The list of accumulated blockage or upgrade events.
+ *
+ * This can be cleared when the user acknowledges the events.
+ *
+ * @type {EventData[]}
+ */
+ #events = [];
+ #backgroundInterval = null;
+
+ /**
+ * The lox ID that is currently active.
+ *
+ * Stays in sync with TorSettings.bridges.lox_id. null when uninitialized.
+ *
+ * @type {string?}
+ */
+ #activeLoxId = null;
+
+ get activeLoxId() {
+ return this.#activeLoxId;
+ }
+
+ /**
+ * Update the active lox id.
+ */
+ #updateActiveLoxId() {
+ const loxId = lazy.TorSettings.bridges.lox_id;
+ if (loxId === this.#activeLoxId) {
+ return;
+ }
+ lazy.logger.debug(
+ `#activeLoxId switching from "${this.#activeLoxId}" to "${loxId}"`
+ );
+ if (this.#activeLoxId !== null) {
+ lazy.logger.debug(
+ `Clearing event data and invites for "${this.#activeLoxId}"`
+ );
+ // If not initializing clear the metadata for the old lox ID when it
+ // changes.
+ this.clearEventData(this.#activeLoxId);
+ // TODO: Do we want to keep invites? See tor-browser#42453
+ this.#invites = [];
+ this.#store();
+ }
+ this.#activeLoxId = loxId;
+
+ Services.obs.notifyObservers(null, LoxTopics.UpdateActiveLoxId);
+ }
+
+ observe(subject, topic) {
+ switch (topic) {
+ case lazy.TorSettingsTopics.SettingsChanged: {
+ const { changes } = subject.wrappedJSObject;
+ if (
+ changes.includes("bridges.enabled") ||
+ changes.includes("bridges.source") ||
+ changes.includes("bridges.lox_id")
+ ) {
+ // The lox_id may have changed.
+ this.#updateActiveLoxId();
+
+ // Only run background tasks if Lox is enabled
+ if (this.#inuse) {
+ if (!this.#backgroundInterval) {
+ this.#backgroundInterval = setInterval(
+ this.#backgroundTasks.bind(this),
+ 1000 * 60 * 60 * 12
+ );
+ }
+ } else if (this.#backgroundInterval) {
+ clearInterval(this.#backgroundInterval);
+ this.#backgroundInterval = null;
+ }
+ }
+ break;
+ }
+ case lazy.TorSettingsTopics.Ready:
+ // Set the initial #activeLoxId.
+ this.#updateActiveLoxId();
+ // Run background tasks every 12 hours if Lox is enabled
+ if (this.#inuse) {
+ this.#backgroundInterval = setInterval(
+ this.#backgroundTasks.bind(this),
+ 1000 * 60 * 60 * 12
+ );
+ }
+ break;
+ }
+ }
+
+ /**
+ * Assert that the module is initialized.
+ */
+ #assertInitialized() {
+ if (!this.enabled || !this.#initialized) {
+ throw new LoxError("Not initialized");
+ }
+ }
+
+ get #inuse() {
+ return (
+ this.enabled &&
+ Boolean(this.#activeLoxId) &&
+ lazy.TorSettings.bridges.enabled === true &&
+ lazy.TorSettings.bridges.source === lazy.TorBridgeSource.Lox
+ );
+ }
+
+ /**
+ * Stores a promise for the last task that was performed to change
+ * credentials for a given lox ID. This promise completes when the task
+ * completes and it is safe to perform a new action on the credentials.
+ *
+ * This essentially acts as a lock on the credential, so that only one task
+ * acts on the credentials at any given time. See tor-browser#42492.
+ *
+ * @type {Map<string, Promise>}
+ */
+ #credentialsTasks = new Map();
+
+ /**
+ * Attempt to change some existing credentials for an ID to a new value.
+ *
+ * Each call for the same lox ID must await the previous call. As such, this
+ * should *never* be called recursively.
+ *
+ * @param {string} loxId - The ID to change the credentials for.
+ * @param {Function} task - The task that performs the change in credentials.
+ * The method is given the current credentials. It should either return the
+ * new credentials as a string, or null if the credentials should not
+ * change, or throw an error which will fall through to the caller.
+ *
+ * @returns {?string} - The credentials returned by the task, if any.
+ */
+ async #changeCredentials(loxId, task) {
+ // Read and replace #credentialsTasks before we do any async operations.
+ // I.e. this is effectively atomic read and replace.
+ const prevTask = this.#credentialsTasks.get(loxId);
+ let taskComplete;
+ this.#credentialsTasks.set(
+ loxId,
+ new Promise(res => {
+ taskComplete = res;
+ })
+ );
+
+ // Wait for any previous task to complete first, to avoid making conflicting
+ // changes to the credentials. See tor-browser#42492.
+ // prevTask is either undefined or a promise that should not throw.
+ await prevTask;
+
+ // Future calls now await us.
+
+ const cred = this.#getCredentials(loxId);
+ let newCred = null;
+ try {
+ // This task may throw, in which case we do not set new credentials.
+ newCred = await task(cred);
+ if (newCred) {
+ this.#credentials.set(loxId, newCred);
+ // Store the new credentials.
+ this.#store();
+ lazy.logger.debug("Changed credentials");
+ }
+ } finally {
+ // Stop awaiting us.
+ taskComplete();
+ }
+
+ if (!newCred) {
+ return null;
+ }
+
+ // Let listeners know we have new credentials. We do this *after* calling
+ // taskComplete to avoid a recursive call to await this.#changeCredentials,
+ // which would cause us to hang.
+
+ // NOTE: In principle we could determine within this module whether the
+ // bridges, remaining invites, or next unlock changes in value when
+ // switching credentials.
+ // However, this logic can be done by the topic observers, as needed. In
+ // particular, TorSettings.bridges.bridge_strings has its own logic
+ // determining whether its value has changed.
+
+ // Let TorSettings know about possibly new bridges.
+ Services.obs.notifyObservers(null, LoxTopics.UpdateBridges);
+ // Let UI know about changes.
+ Services.obs.notifyObservers(null, LoxTopics.UpdateRemainingInvites);
+ Services.obs.notifyObservers(null, LoxTopics.UpdateNextUnlock);
+
+ return newCred;
+ }
+
+ /**
+ * Fetch the latest credentials.
+ *
+ * @param {string} loxId - The ID to get the credentials for.
+ *
+ * @returns {string} - The credentials.
+ */
+ #getCredentials(loxId) {
+ const cred = loxId ? this.#credentials.get(loxId) : undefined;
+ if (!cred) {
+ throw new LoxError(`No credentials for ${loxId}`);
+ }
+ return cred;
+ }
+
+ /**
+ * Formats and returns bridges from the stored Lox credential.
+ *
+ * @param {string} loxId The id string associated with a lox credential.
+ *
+ * @returns {string[]} An array of formatted bridge lines. The array is empty
+ * if there are no bridges.
+ */
+ getBridges(loxId) {
+ this.#assertInitialized();
+ // Note: this is messy now but can be mostly removed after we have
+ // https://gitlab.torproject.org/tpo/anti-censorship/lox/-/issues/46
+ let bridgelines = JSON.parse(this.#getCredentials(loxId)).bridgelines;
+ let bridges = [];
+ for (const bridge of bridgelines) {
+ let addr = bridge.addr;
+ while (addr[addr.length - 1] === 0) {
+ addr.pop();
+ }
+ addr = new Uint8Array(addr);
+ let decoder = new TextDecoder("utf-8");
+ addr = decoder.decode(addr);
+
+ let info = bridge.info;
+ while (info[info.length - 1] === 0) {
+ info.pop();
+ }
+ info = new Uint8Array(info);
+ info = decoder.decode(info);
+
+ let regexpTransport = /type=([a-zA-Z0-9]*)/;
+ let transport = info.match(regexpTransport);
+ if (transport !== null) {
+ transport = transport[1];
+ } else {
+ transport = "";
+ }
+
+ let regexpFingerprint = /fingerprint=\"([a-zA-Z0-9]*)\"/;
+ let fingerprint = info.match(regexpFingerprint);
+ if (fingerprint !== null) {
+ fingerprint = fingerprint[1];
+ } else {
+ fingerprint = "";
+ }
+
+ let regexpParams = /params=Some\(\{(.*)\}\)/;
+ let params = info.match(regexpParams);
+ if (params !== null) {
+ params = params[1]
+ .replaceAll('"', "")
+ .replaceAll(": ", "=")
+ .replaceAll(",", " ");
+ } else {
+ params = "";
+ }
+
+ bridges.push(
+ `${transport} ${addr}:${bridge.port} ${fingerprint} ${params}`
+ );
+ }
+ return bridges;
+ }
+
+ #store() {
+ Services.prefs.setStringPref(LoxSettingsPrefs.pubkeys, this.#pubKeys);
+ Services.prefs.setStringPref(LoxSettingsPrefs.enctable, this.#encTable);
+ Services.prefs.setStringPref(LoxSettingsPrefs.constants, this.#constants);
+ Services.prefs.setStringPref(
+ LoxSettingsPrefs.credentials,
+ JSON.stringify(Object.fromEntries(this.#credentials))
+ );
+ Services.prefs.setStringPref(
+ LoxSettingsPrefs.invites,
+ JSON.stringify(this.#invites)
+ );
+ Services.prefs.setStringPref(
+ LoxSettingsPrefs.events,
+ JSON.stringify(this.#events)
+ );
+ }
+
+ #load() {
+ const cred = Services.prefs.getStringPref(LoxSettingsPrefs.credentials, "");
+ this.#credentials = new Map(cred ? Object.entries(JSON.parse(cred)) : []);
+ const invites = Services.prefs.getStringPref(LoxSettingsPrefs.invites, "");
+ this.#invites = invites ? JSON.parse(invites) : [];
+ const events = Services.prefs.getStringPref(LoxSettingsPrefs.events, "");
+ this.#events = events ? JSON.parse(events) : [];
+ this.#pubKeys = Services.prefs.getStringPref(
+ LoxSettingsPrefs.pubkeys,
+ null
+ );
+ this.#encTable = Services.prefs.getStringPref(
+ LoxSettingsPrefs.enctable,
+ null
+ );
+ this.#constants = Services.prefs.getStringPref(
+ LoxSettingsPrefs.constants,
+ null
+ );
+ }
+
+ /**
+ * Update Lox credential after Lox key rotation.
+ *
+ * Do not call directly, use #getPubKeys() instead to start the update only
+ * once.
+ */
+ async #updatePubkeys() {
+ let pubKeys = await this.#makeRequest("pubkeys", null);
+ const prevKeys = this.#pubKeys;
+ if (prevKeys !== null) {
+ // check if the lox pubkeys have changed and update the lox
+ // credentials if so.
+ await this.#changeCredentials(this.#activeLoxId, async cred => {
+ // The UpdateCredOption rust struct serializes to "req" rather than
+ // "request".
+ const { updated, req: request } = JSON.parse(
+ lazy.check_lox_pubkeys_update(pubKeys, prevKeys, cred)
+ );
+ if (!updated) {
+ return null;
+ }
+ // Try update credentials.
+ // NOTE: This should be re-callable if any step fails.
+ // TODO: Verify this.
+ lazy.logger.debug(
+ `Lox pubkey updated, update Lox credential "${this.#activeLoxId}"`
+ );
+ // TODO: If this call doesn't succeed due to a networking error, the Lox
+ // credential may be in an unusable state (spent but not updated)
+ // until this request can be completed successfully (and until Lox
+ // is refactored to send repeat responses:
+ // https://gitlab.torproject.org/tpo/anti-censorship/lox/-/issues/74)
+ let response = await this.#makeRequest("updatecred", request);
+ return lazy.handle_update_cred(request, response, pubKeys);
+ });
+ }
+ // If we arrive here we haven't had other errors before, we can actually
+ // store the new public key.
+ this.#pubKeys = pubKeys;
+ this.#store();
+ }
+
+ async #getPubKeys() {
+ // FIXME: We are always refetching #pubKeys, #encTable and #constants once
+ // per session, but they may change more frequently. tor-browser#42502
+ if (this.#pubKeyPromise === null) {
+ this.#pubKeyPromise = this.#updatePubkeys().catch(error => {
+ lazy.logger.debug("Failed to update pubKeys", error);
+ // Try again with the next call.
+ this.#pubKeyPromise = null;
+ if (!this.#pubKeys) {
+ // Re-throw if we have no pubKeys value for the caller.
+ throw error;
+ }
+ });
+ }
+ await this.#pubKeyPromise;
+ }
+
+ async #getEncTable() {
+ if (this.#encTablePromise === null) {
+ this.#encTablePromise = this.#makeRequest("reachability", null)
+ .then(encTable => {
+ this.#encTable = encTable;
+ this.#store();
+ })
+ .catch(error => {
+ lazy.logger.debug("Failed to get encTable", error);
+ // Make the next call try again.
+ this.#encTablePromise = null;
+ // Try to update first, but if that doesn't work fall back to stored data
+ if (!this.#encTable) {
+ throw error;
+ }
+ });
+ }
+ await this.#encTablePromise;
+ }
+
+ async #getConstants() {
+ if (this.#constantsPromise === null) {
+ // Try to update first, but if that doesn't work fall back to stored data
+ this.#constantsPromise = this.#makeRequest("constants", null)
+ .then(constants => {
+ const prevValue = this.#constants;
+ this.#constants = constants;
+ this.#store();
+ if (prevValue !== this.#constants) {
+ Services.obs.notifyObservers(null, LoxTopics.UpdateNextUnlock);
+ }
+ })
+ .catch(error => {
+ lazy.logger.debug("Failed to get constants", error);
+ // Make the next call try again.
+ this.#constantsPromise = null;
+ if (!this.#constants) {
+ throw error;
+ }
+ });
+ }
+ await this.#constantsPromise;
+ }
+
+ /**
+ * Parse a decimal string to a non-negative integer.
+ *
+ * @param {string} str - The string to parse.
+ * @returns {integer} - The integer.
+ */
+ static #parseNonNegativeInteger(str) {
+ if (typeof str !== "string" || !/^[0-9]+$/.test(str)) {
+ throw new LoxError(`Expected a non-negative decimal integer: "${str}"`);
+ }
+ return parseInt(str, 10);
+ }
+
+ /**
+ * Get the current lox trust level.
+ *
+ * @param {string} loxId - The ID to fetch the level for.
+ * @returns {integer} - The trust level.
+ */
+ #getLevel(loxId) {
+ return LoxImpl.#parseNonNegativeInteger(
+ lazy.get_trust_level(this.#getCredentials(loxId))
+ );
+ }
+
+ /**
+ * Check for blockages and attempt to perform a levelup
+ *
+ * If either blockages or a levelup happened, add an event to the event queue
+ */
+ async #backgroundTasks() {
+ this.#assertInitialized();
+ // Only run background tasks for the active lox ID.
+ const loxId = this.#activeLoxId;
+ if (!loxId) {
+ lazy.logger.warn("No loxId for the background task");
+ return;
+ }
+
+ // Attempt to update pubkeys each time background tasks are run
+ // this should catch key rotations (ideally some days) prior to the next
+ // credential update
+ await this.#getPubKeys();
+ let levelup = false;
+ try {
+ levelup = await this.#attemptUpgrade(loxId);
+ } catch (error) {
+ lazy.logger.error(error);
+ }
+ if (levelup) {
+ const level = this.#getLevel(loxId);
+ const newEvent = {
+ type: "levelup",
+ newlevel: level,
+ };
+ this.#events.push(newEvent);
+ this.#store();
+ }
+
+ let leveldown = false;
+ try {
+ leveldown = await this.#blockageMigration(loxId);
+ } catch (error) {
+ lazy.logger.error(error);
+ }
+ if (leveldown) {
+ let level = this.#getLevel(loxId);
+ const newEvent = {
+ type: "blockage",
+ newlevel: level,
+ };
+ this.#events.push(newEvent);
+ this.#store();
+ }
+
+ if (levelup || leveldown) {
+ Services.obs.notifyObservers(null, LoxTopics.UpdateEvents);
+ }
+ }
+
+ /**
+ * Generates a new random lox id to be associated with an invitation/credential
+ *
+ * @returns {string}
+ */
+ #genLoxId() {
+ return crypto.randomUUID();
+ }
+
+ async init() {
+ if (!this.enabled) {
+ lazy.logger.info(
+ "Skipping initialization since Lox module is not enabled"
+ );
+ return;
+ }
+ // If lox_id is set, load it
+ Services.obs.addObserver(this, lazy.TorSettingsTopics.SettingsChanged);
+ Services.obs.addObserver(this, lazy.TorSettingsTopics.Ready);
+
+ // Hack to make the generated wasm happy
+ const win = { crypto };
+ win.window = win;
+ await lazy.init(win);
+ lazy.set_panic_hook();
+ if (typeof lazy.open_invite !== "function") {
+ throw new LoxError("Initialization failed");
+ }
+ this.#load();
+ this.#initialized = true;
+ }
+
+ async uninit() {
+ if (!this.enabled) {
+ return;
+ }
+ Services.obs.removeObserver(this, lazy.TorSettingsTopics.SettingsChanged);
+ Services.obs.removeObserver(this, lazy.TorSettingsTopics.Ready);
+ if (this.#domainFrontedRequests !== null) {
+ try {
+ const domainFronting = await this.#domainFrontedRequests;
+ domainFronting.uninit();
+ } catch {}
+ this.#domainFrontedRequests = null;
+ }
+ this.#initialized = false;
+ this.#invites = [];
+ this.#pubKeys = null;
+ this.#encTable = null;
+ this.#constants = null;
+ this.#pubKeyPromise = null;
+ this.#encTablePromise = null;
+ this.#constantsPromise = null;
+ this.#credentials = new Map();
+ this.#events = [];
+ if (this.#backgroundInterval) {
+ clearInterval(this.#backgroundInterval);
+ }
+ this.#backgroundInterval = null;
+ }
+
+ /**
+ * Parses an input string to check if it is a valid Lox invitation.
+ *
+ * @param {string} invite A Lox invitation.
+ * @returns {bool} Whether the value passed in was a Lox invitation.
+ */
+ validateInvitation(invite) {
+ this.#assertInitialized();
+ try {
+ lazy.invitation_is_trusted(invite);
+ } catch (err) {
+ lazy.logger.info(`Does not parse as an invite: "${invite}".`, err);
+ return false;
+ }
+ return true;
+ }
+
+ // Note: This is only here for testing purposes. We're going to be using telegram
+ // to issue open invitations for Lox bridges.
+ async requestOpenInvite() {
+ this.#assertInitialized();
+ let invite = JSON.parse(await this.#makeRequest("invite", null));
+ lazy.logger.debug(invite);
+ return invite;
+ }
+
+ /**
+ * Redeems a Lox open invitation to obtain an untrusted Lox credential
+ * and 1 bridge.
+ *
+ * @param {string} invite A Lox open invitation.
+ * @returns {string} The loxId of the associated credential on success.
+ */
+ async redeemInvite(invite) {
+ this.#assertInitialized();
+ // It's fine to get pubkey here without a delay since the user will not have a Lox
+ // credential yet
+ await this.#getPubKeys();
+ // NOTE: We currently only handle "open invites".
+ // "trusted invites" are not yet supported. tor-browser#42974.
+ let request = await lazy.open_invite(JSON.parse(invite).invite);
+ let response;
+ try {
+ response = await this.#makeRequest("openreq", request);
+ } catch (error) {
+ if (error instanceof LoxError && error.code === LoxError.ErrorResponse) {
+ throw new LoxError("Error response to openreq", LoxError.BadInvite);
+ } else {
+ throw error;
+ }
+ }
+ let cred = lazy.handle_new_lox_credential(request, response, this.#pubKeys);
+ // Generate an id that is not already in the #credentials map.
+ let loxId;
+ do {
+ loxId = this.#genLoxId();
+ } while (this.#credentials.has(loxId));
+ // Set new credentials.
+ this.#credentials.set(loxId, cred);
+ this.#store();
+ return loxId;
+ }
+
+ /**
+ * Get metadata on all invites historically generated by this credential.
+ *
+ * @returns {string[]} A list of all historical invites.
+ */
+ getInvites() {
+ this.#assertInitialized();
+ // Return a copy.
+ return structuredClone(this.#invites);
+ }
+
+ /**
+ * Generates a new trusted Lox invitation that a user can pass to their
+ * contacts.
+ *
+ * Throws if:
+ * - there is no saved Lox credential, or
+ * - the saved credential does not have any invitations available.
+ *
+ * @param {string} loxId - The ID to generate an invite for.
+ * @returns {string} A valid Lox invitation.
+ */
+ async generateInvite(loxId) {
+ this.#assertInitialized();
+ // Check for pubkey update prior to invitation generation
+ // to avoid invite being generated with outdated keys
+ // TODO: it's still possible the keys could be rotated before the invitation is redeemed
+ // so updating the invite after issuing should also be possible somehow
+ if (this.#pubKeys == null) {
+ // TODO: Set pref to call this function after some time #43086
+ // See also: https://gitlab.torproject.org/tpo/applications/tor-browser/-/merge_requests/1090#note_3066911
+ await this.#getPubKeys();
+ throw new LoxError(
+ `Pubkeys just updated, retry later to avoid deanonymization`
+ );
+ }
+ await this.#getEncTable();
+ let level = this.#getLevel(loxId);
+ if (level < 1) {
+ throw new LoxError(`Cannot generate invites at level ${level}`);
+ }
+
+ const cred = await this.#changeCredentials(loxId, async cred => {
+ let request = lazy.issue_invite(cred, this.#encTable, this.#pubKeys);
+ let response = await this.#makeRequest("issueinvite", request);
+ // TODO: Do we ever expect handle_issue_invite to fail (beyond
+ // implementation bugs)?
+ // TODO: What happens if #pubkeys for `issue_invite` differs from the value
+ // when calling `handle_issue_invite`? Should we cache the value at the
+ // start of this method?
+ return lazy.handle_issue_invite(request, response, this.#pubKeys);
+ });
+
+ const invite = lazy.prepare_invite(cred);
+ this.#invites.push(invite);
+ // cap length of stored invites
+ if (this.#invites.len > 50) {
+ this.#invites.shift();
+ }
+ this.#store();
+ Services.obs.notifyObservers(null, LoxTopics.NewInvite);
+ // Return a copy.
+ // Right now invite is just a string, but that might change in the future.
+ return structuredClone(invite);
+ }
+
+ /**
+ * Get the number of invites that a user has remaining.
+ *
+ * @param {string} loxId - The ID to check.
+ * @returns {int} The number of invites that can still be generated by a
+ * user's credential.
+ */
+ getRemainingInviteCount(loxId) {
+ this.#assertInitialized();
+ return LoxImpl.#parseNonNegativeInteger(
+ lazy.get_invites_remaining(this.#getCredentials(loxId))
+ );
+ }
+
+ async #blockageMigration(loxId) {
+ return Boolean(
+ await this.#changeCredentials(loxId, async cred => {
+ let request;
+ try {
+ request = lazy.check_blockage(cred, this.#pubKeys);
+ } catch {
+ lazy.logger.log("Not ready for blockage migration");
+ return null;
+ }
+ let response = await this.#makeRequest("checkblockage", request);
+ // NOTE: If a later method fails, we should be ok to re-call "checkblockage"
+ // from the Lox authority. So there shouldn't be any adverse side effects to
+ // loosing migrationCred.
+ // TODO: Confirm this is safe to lose.
+ const migrationCred = lazy.handle_check_blockage(cred, response);
+ request = lazy.blockage_migration(cred, migrationCred, this.#pubKeys);
+ response = await this.#makeRequest("blockagemigration", request);
+ return lazy.handle_blockage_migration(cred, response, this.#pubKeys);
+ })
+ );
+ }
+
+ /**
+ * Attempts to upgrade the currently saved Lox credential.
+ * If an upgrade is available, save an event in the event list.
+ *
+ * @param {string} loxId Lox ID
+ * @returns {boolean} Whether the credential was successfully migrated.
+ */
+ async #attemptUpgrade(loxId) {
+ await this.#getEncTable();
+ await this.#getConstants();
+ let level = this.#getLevel(loxId);
+ if (level < 1) {
+ // attempt trust promotion instead
+ return this.#trustMigration(loxId);
+ }
+ return Boolean(
+ await this.#changeCredentials(loxId, async cred => {
+ let request = lazy.level_up(cred, this.#encTable, this.#pubKeys);
+ let response;
+ try {
+ response = await this.#makeRequest("levelup", request);
+ } catch (error) {
+ if (
+ error instanceof LoxError &&
+ error.code === LoxError.ErrorResponse
+ ) {
+ // Not an error.
+ lazy.logger.debug("Not ready for level up", error);
+ return null;
+ }
+ throw error;
+ }
+ return lazy.handle_level_up(request, response, this.#pubKeys);
+ })
+ );
+ }
+
+ /**
+ * Attempt to migrate from an untrusted to a trusted Lox credential
+ *
+ * @param {string} loxId - The ID to use.
+ * @returns {boolean} Whether the credential was successfully migrated.
+ */
+ async #trustMigration(loxId) {
+ if (this.#pubKeys == null) {
+ // TODO: Set pref to call this function after some time #43086
+ // See also: https://gitlab.torproject.org/tpo/applications/tor-browser/-/merge_requests/1090#note_3066911
+ this.#getPubKeys();
+ return false;
+ }
+ return Boolean(
+ await this.#changeCredentials(loxId, async cred => {
+ let request;
+ try {
+ request = lazy.trust_promotion(cred, this.#pubKeys);
+ } catch (err) {
+ // This function is called routinely during the background tasks without
+ // previous checks on whether an upgrade is possible, so it is expected to
+ // fail with a certain frequency. Therefore, do not relay the error to the
+ // caller and just log the message for debugging.
+ lazy.logger.debug("Not ready to upgrade", err);
+ return null;
+ }
+
+ let response = await this.#makeRequest("trustpromo", request);
+ // FIXME: Store response to "trustpromo" in case handle_trust_promotion
+ // or "trustmig" fails. The Lox authority will not accept a re-request
+ // to "trustpromo" with the same credentials.
+ let promoCred = lazy.handle_trust_promotion(request, response);
+ lazy.logger.debug("Formatted promotion cred: ", promoCred);
+
+ request = lazy.trust_migration(cred, promoCred, this.#pubKeys);
+ response = await this.#makeRequest("trustmig", request);
+ lazy.logger.debug("Got new credential: ", response);
+
+ // FIXME: Store response to "trustmig" in case handle_trust_migration
+ // fails. The Lox authority will not accept a re-request to "trustmig" with
+ // the same credentials.
+ return lazy.handle_trust_migration(request, response);
+ })
+ );
+ }
+
+ /**
+ * @typedef {object} EventData
+ *
+ * @property {string} [type] - the type of event. This should be one of:
+ * ("levelup", "blockage")
+ * @property {integer} [newlevel] - the new level, after the event. Levels count
+ * from 0, but "blockage" events can never take the user to 0, so this will always
+ * be 1 or greater.
+ */
+
+ /**
+ * Get a list of accumulated events.
+ *
+ * @param {string} loxId - The ID to get events for.
+ * @returns {EventData[]} A list of the accumulated, unacknowledged events
+ * associated with a user's credential.
+ */
+ getEventData(loxId) {
+ this.#assertInitialized();
+ if (loxId !== this.#activeLoxId) {
+ lazy.logger.warn(
+ `No event data for loxId ${loxId} since it was replaced by ${
+ this.#activeLoxId
+ }`
+ );
+ return [];
+ }
+ // Return a copy.
+ return structuredClone(this.#events);
+ }
+
+ /**
+ * Clears accumulated event data.
+ *
+ * Should be called whenever the user acknowledges the existing events.
+ *
+ * @param {string} loxId - The ID to clear events for.
+ */
+ clearEventData(loxId) {
+ this.#assertInitialized();
+ if (loxId !== this.#activeLoxId) {
+ lazy.logger.warn(
+ `Not clearing event data for loxId ${loxId} since it was replaced by ${
+ this.#activeLoxId
+ }`
+ );
+ return;
+ }
+ this.#events = [];
+ this.#store();
+ Services.obs.notifyObservers(null, LoxTopics.UpdateEvents);
+ }
+
+ /**
+ * @typedef {object} UnlockData
+ *
+ * @property {string} date - The date-time for the next level up, formatted as
+ * YYYY-MM-DDTHH:mm:ssZ.
+ * @property {integer} nextLevel - The next level. Levels count from 0, so
+ * this will be 1 or greater.
+ */
+
+ /**
+ * Get details about the next feature unlock.
+ *
+ * NOTE: A call to this method may trigger LoxTopics.UpdateNextUnlock.
+ *
+ * @param {string} loxId - The ID to get the unlock for.
+ * @returns {UnlockData} - Details about the next unlock.
+ */
+ async getNextUnlock(loxId) {
+ this.#assertInitialized();
+ await this.#getConstants();
+ let nextUnlock = JSON.parse(
+ lazy.get_next_unlock(this.#constants, this.#getCredentials(loxId))
+ );
+ const level = this.#getLevel(loxId);
+ return {
+ date: nextUnlock.trust_level_unlock_date,
+ nextLevel: level + 1,
+ };
+ }
+
+ /**
+ * Fetch from the Lox authority.
+ *
+ * @param {string} procedure - The request endpoint.
+ * @param {string} body - The arguments to send in the body, if any.
+ *
+ * @returns {string} - The response body.
+ */
+ async #fetch(procedure, body) {
+ // TODO: Customize to for Lox
+ const url = `https://lox.torproject.org/${procedure}`;
+ const method = "POST";
+ const contentType = "application/vnd.api+json";
+
+ if (lazy.TorConnect.stageName === lazy.TorConnectStage.Bootstrapped) {
+ let request;
+ try {
+ request = await fetch(url, {
+ method,
+ headers: { "Content-Type": contentType },
+ body,
+ });
+ } catch (error) {
+ lazy.logger.debug("fetch fail", url, body, error);
+ throw new LoxError(
+ `fetch "${procedure}" from Lox authority failed: ${error?.message}`,
+ LoxError.LoxServerUnreachable
+ );
+ }
+ if (!request.ok) {
+ lazy.logger.debug("fetch response", url, body, request);
+ // Do not treat as a LoxServerUnreachable type.
+ throw new LoxError(
+ `Lox authority responded to "${procedure}" with ${request.status}: ${request.statusText}`
+ );
+ }
+ return request.text();
+ }
+
+ // TODO: Only make domain fronted requests with user permission.
+ // tor-browser#42606.
+ if (this.#domainFrontedRequests === null) {
+ this.#domainFrontedRequests = new Promise((resolve, reject) => {
+ // TODO: Customize to the values for Lox
+ const reflector = Services.prefs.getStringPref(
+ "extensions.torlauncher.bridgedb_reflector"
+ );
+ const front = Services.prefs.getStringPref(
+ "extensions.torlauncher.bridgedb_front"
+ );
+ const builder = new lazy.DomainFrontRequestBuilder();
+ builder
+ .init(reflector, front)
+ .then(() => resolve(builder))
+ .catch(reject);
+ });
+ }
+ const builder = await this.#domainFrontedRequests;
+ try {
+ return await builder.buildRequest(url, { method, contentType, body });
+ } catch (error) {
+ lazy.logger.debug("Domain front request fail", url, body, error);
+ if (error instanceof lazy.DomainFrontRequestNetworkError) {
+ throw new LoxError(
+ `Domain front fetch "${procedure}" from Lox authority failed: ${error?.message}`,
+ LoxError.LoxServerUnreachable
+ );
+ }
+ if (error instanceof lazy.DomainFrontRequestResponseError) {
+ // Do not treat as a LoxServerUnreachable type.
+ throw new LoxError(
+ `Lox authority responded to domain front "${procedure}" with ${error.status}: ${error.statusText}`
+ );
+ }
+ throw new LoxError(
+ `Domain front request for "${procedure}" from Lox authority failed: ${error?.message}`
+ );
+ }
+ }
+
+ /**
+ * Make a request to the lox authority, check for an error response, and
+ * convert it to a string.
+ *
+ * @param {string} procedure - The request endpoint.
+ * @param {?string} request - The request data, as a JSON string containing a
+ * "request" field. Or `null` to send no data.
+ *
+ * @returns {string} - The stringified JSON response.
+ */
+ async #makeRequest(procedure, request) {
+ // Verify that the response is valid json, by parsing.
+ const jsonResponse = JSON.parse(
+ await this.#fetch(
+ procedure,
+ request ? JSON.stringify(JSON.parse(request).request) : ""
+ )
+ );
+ lazy.logger.debug(`${procedure} response:`, jsonResponse);
+ if (Object.hasOwn(jsonResponse, "error")) {
+ // TODO: Figure out if any of the "error" responses should be treated as
+ // an error. I.e. which of the procedures have soft failures and hard
+ // failures.
+ throw LoxError(
+ `Error response to ${procedure}: ${jsonResponse.error}`,
+ LoxError.ErrorResponse
+ );
+ }
+ return JSON.stringify(jsonResponse);
+ }
+}
+
+export const Lox = new LoxImpl();
diff --git a/toolkit/components/lox/jar.mn b/toolkit/components/lox/jar.mn
@@ -0,0 +1,4 @@
+toolkit.jar:
+#ifndef ANDROID
+ content/global/lox/lox_wasm_bg.wasm (content/lox_wasm_bg.wasm)
+#endif
diff --git a/toolkit/components/lox/moz.build b/toolkit/components/lox/moz.build
@@ -0,0 +1,7 @@
+EXTRA_JS_MODULES += [
+ "Lox.sys.mjs",
+ # Let's keep the old jsm format until wasm-bindgen is updated
+ "lox_wasm.jsm",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/toolkit/components/moz.build b/toolkit/components/moz.build
@@ -53,6 +53,7 @@ DIRS += [
"httpsonlyerror",
"jsoncpp/src/lib_json",
"kvstore",
+ "lox",
"media",
"mediasniffer",
# Exclude the "ml" component. tor-browser#44045.