Verified Commit 99dff767 authored by Pier Angelo Vendrame's avatar Pier Angelo Vendrame 🎃 Committed by ma1
Browse files

Bug 40458: Implement .tor.onion aliases

We have enabled HTTPS-Only mode, therefore we do not need
HTTPS-Everywhere anymore.
However, we want to keep supporting .tor.onion aliases (especially for
securedrop).
Therefore, in this patch we implemented the parsing of HTTPS-Everywhere
rulesets, and the redirect of .tor.onion domains.
Actually, Tor Browser believes they are actual domains. We change them
on the fly on the SOCKS proxy requests to resolve the domain, and on
the code that verifies HTTPS certificates.
parent 7d8b47fb
Loading
Loading
Loading
Loading
+41 −0
Original line number Diff line number Diff line
@@ -67,6 +67,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
    "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
  TRRRacer: "resource:///modules/TRRPerformance.sys.mjs",
  TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs",
  TorConnect: "resource://gre/modules/TorConnect.sys.mjs",
  TorConnectTopics: "resource://gre/modules/TorConnect.sys.mjs",
  TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs",
  UIState: "resource://services-sync/UIState.sys.mjs",
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
@@ -88,6 +90,7 @@ XPCOMUtils.defineLazyModuleGetters(lazy, {
  NetUtil: "resource://gre/modules/NetUtil.jsm",
  OnboardingMessageProvider:
    "resource://activity-stream/lib/OnboardingMessageProvider.jsm",
  OnionAliasStore: "resource:///modules/OnionAliasStore.jsm",
  PageActions: "resource:///modules/PageActions.jsm",
  ProcessHangMonitor: "resource:///modules/ProcessHangMonitor.jsm",
  TabCrashHandler: "resource:///modules/ContentCrashHandlers.jsm",
@@ -866,6 +869,19 @@ let JSWINDOWACTORS = {
    enablePreference: "accessibility.blockautorefresh",
  },

  Rulesets: {
    parent: {
      esModuleURI: "resource:///modules/RulesetsParent.sys.mjs",
    },
    child: {
      esModuleURI: "resource:///modules/RulesetsChild.sys.mjs",
      events: {
        DOMWindowCreated: {},
      },
    },
    matches: ["about:rulesets*"],
  },

  ScreenshotsComponent: {
    parent: {
      esModuleURI: "resource:///modules/ScreenshotsUtils.sys.mjs",
@@ -2150,6 +2166,7 @@ BrowserGlue.prototype = {
          lazy.UpdateListener.reset();
        }
      },
      () => lazy.OnionAliasStore.uninit(),
    ];

    for (let task of tasks) {
@@ -2856,6 +2873,30 @@ BrowserGlue.prototype = {
        },
      },

      {
        task: () => {
          if (!lazy.TorConnect.shouldShowTorConnect) {
            // we will take this path when the user is using the legacy tor launcher or
            // when Tor Browser didn't launch its own tor.
            lazy.OnionAliasStore.init();
          } else {
            // this path is taken when using about:torconnect, we wait to init
            // after we are bootstrapped and connected to tor
            const topic = lazy.TorConnectTopics.BootstrapComplete;
            let bootstrapObserver = {
              observe(aSubject, aTopic, aData) {
                if (aTopic === topic) {
                  lazy.OnionAliasStore.init();
                  // we only need to init once, so remove ourselves as an obvserver
                  Services.obs.removeObserver(this, topic);
                }
              },
            };
            Services.obs.addObserver(bootstrapObserver, topic);
          }
        },
      },

      {
        name: "TabUnloader.init",
        task: () => {
+5 −0
Original line number Diff line number Diff line
@@ -85,6 +85,11 @@ static const RedirEntry kRedirMap[] = {
    {"robots", "chrome://browser/content/aboutRobots.xhtml",
     nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT |
         nsIAboutModule::ALLOW_SCRIPT},
    {"rulesets", "chrome://browser/content/rulesets/aboutRulesets.html",
     nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::URI_MUST_LOAD_IN_CHILD |
         nsIAboutModule::URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS |
         nsIAboutModule::URI_SAFE_FOR_UNTRUSTED_CONTENT |
         nsIAboutModule::IS_SECURE_CHROME_UI},
    {"sessionrestore", "chrome://browser/content/aboutSessionRestore.xhtml",
     nsIAboutModule::ALLOW_SCRIPT | nsIAboutModule::HIDE_FROM_ABOUTABOUT |
         nsIAboutModule::IS_SECURE_CHROME_UI},
+1 −0
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ pages = [
    'restartrequired',
    'rights',
    'robots',
    'rulesets',
    'sessionrestore',
    'tabcrashed',
    'unloads',
+1 −0
Original line number Diff line number Diff line
@@ -50,6 +50,7 @@ DIRS += [
    "prompts",
    "protocolhandler",
    "resistfingerprinting",
    "rulesets",
    "screenshots",
    "search",
    "securitylevel",
+537 −0
Original line number Diff line number Diff line
// Copyright (c) 2022, The Tor Project, Inc.

import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
import { ConsoleAPI } from "resource://gre/modules/Console.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
  TorRequestWatch: "resource:///modules/TorRequestWatch.sys.mjs",
});

/* OnionAliasStore observer topics */
export const OnionAliasStoreTopics = Object.freeze({
  ChannelsChanged: "onionaliasstore:channels-changed",
});

const SECURE_DROP = {
  name: "SecureDropTorOnion2021",
  pathPrefix: "https://securedrop.org/https-everywhere-2021/",
  jwk: {
    kty: "RSA",
    e: "AQAB",
    n: "vsC7BNafkRe8Uh1DUgCkv6RbPQMdJgAKKnWdSqQd7tQzU1mXfmo_k1Py_2MYMZXOWmqSZ9iwIYkykZYywJ2VyMGve4byj1sLn6YQoOkG8g5Z3V4y0S2RpEfmYumNjTzfq8nxtLnwjaYd4sCUd5wa0SzeLrpRQuXo2bF3QuUF2xcbLJloxX1MmlsMMCdBc-qGNonLJ7bpn_JuyXlDWy1Fkeyw1qgjiOdiRIbMC1x302zgzX6dSrBrNB8Cpsh-vCE0ZjUo8M9caEv06F6QbYmdGJHM0ZZY34OHMSNdf-_qUKIV_SuxuSuFE99tkAeWnbWpyI1V-xhVo1sc7NzChP8ci2TdPvI3_0JyAuCvL6zIFqJUJkZibEUghhg6F09-oNJKpy7rhUJq7zZyLXJsvuXnn0gnIxfjRvMcDfZAKUVMZKRdw7fwWzwQril4Ib0MQOVda9vb_4JMk7Gup-TUI4sfuS4NKwsnKoODIO-2U5QpJWdtp1F4AQ1pBv8ajFl1WTrVGvkRGK0woPWaO6pWyJ4kRnhnxrV2FyNNt3JSR-0JEjhFWws47kjBvpr0VRiVRFppKA-plKs4LPlaaCff39TleYmY3mETe3w1GIGc2Lliad32Jpbx496IgDe1K3FMBEoKFZfhmtlRSXft8NKgSzPt2zkatM9bFKfaCYRaSy7akbk",
  },
  scope: /^https?:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.securedrop\.tor\.onion\//,
  enabled: true,
  mappings: [],
  currentTimestamp: 0,
};

const kPrefOnionAliasEnabled = "browser.urlbar.onionRewrites.enabled";
const kPrefOnionAliasLogLevel = "browser.onionalias.log_level";

const log = new ConsoleAPI({
  maxLogLevel: "warn",
  maxLogLevelPref: kPrefOnionAliasLogLevel,
  prefix: "OnionAlias",
});

// Inspired by aboutMemory.js and PingCentre.jsm
function gunzip(buffer) {
  return new Promise((resolve, reject) => {
    const listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
      Ci.nsIStreamLoader
    );
    listener.init({
      onStreamComplete(loader, context, status, length, result) {
        resolve(String.fromCharCode(...result));
      },
    });
    const scs = Cc["@mozilla.org/streamConverters;1"].getService(
      Ci.nsIStreamConverterService
    );
    const converter = scs.asyncConvertData(
      "gzip",
      "uncompressed",
      listener,
      null
    );
    const stream = Cc[
      "@mozilla.org/io/arraybuffer-input-stream;1"
    ].createInstance(Ci.nsIArrayBufferInputStream);
    stream.setData(buffer, 0, buffer.byteLength);
    converter.onStartRequest(null, null);
    converter.onDataAvailable(null, stream, 0, buffer.byteLength);
    converter.onStopRequest(null, null, null);
  });
}

class Channel {
  static get SIGN_ALGORITHM() {
    return {
      name: "RSA-PSS",
      saltLength: 32,
      hash: { name: "SHA-256" },
    };
  }

  constructor(name, pathPrefix, jwk, scope, enabled) {
    this.name = name;
    this.pathPrefix = pathPrefix;
    this.jwk = jwk;
    this.scope = scope;
    this._enabled = enabled;

    this.mappings = [];
    this.currentTimestamp = 0;
    this.latestTimestamp = 0;
  }

  async updateLatestTimestamp() {
    const timestampUrl = this.pathPrefix + "/latest-rulesets-timestamp";
    log.debug(`Updating ${this.name} timestamp from ${timestampUrl}`);
    const response = await fetch(timestampUrl);
    if (!response.ok) {
      throw Error(`Could not fetch timestamp for ${this.name}`, {
        cause: response.status,
      });
    }
    const timestampStr = await response.text();
    const timestamp = parseInt(timestampStr);
    // Avoid hijacking, sanitize the timestamp
    if (isNaN(timestamp)) {
      throw Error("Latest timestamp is not a number");
    }
    log.debug(`Updated ${this.name} timestamp: ${timestamp}`);
    this.latestTimestamp = timestamp;
  }

  async makeKey() {
    return crypto.subtle.importKey(
      "jwk",
      this.jwk,
      Channel.SIGN_ALGORITHM,
      false,
      ["verify"]
    );
  }

  async downloadVerifiedRules() {
    log.debug(`Downloading and verifying ruleset for ${this.name}`);

    const key = await this.makeKey();
    const signatureUrl =
      this.pathPrefix + `/rulesets-signature.${this.latestTimestamp}.sha256`;
    const signatureResponse = await fetch(signatureUrl);
    if (!signatureResponse.ok) {
      throw Error("Could not fetch the rules signature");
    }
    const signature = await signatureResponse.arrayBuffer();

    const rulesUrl =
      this.pathPrefix + `/default.rulesets.${this.latestTimestamp}.gz`;
    const rulesResponse = await fetch(rulesUrl);
    if (!rulesResponse.ok) {
      throw Error("Could not fetch rules");
    }
    const rulesGz = await rulesResponse.arrayBuffer();

    if (
      !(await crypto.subtle.verify(
        Channel.SIGN_ALGORITHM,
        key,
        signature,
        rulesGz
      ))
    ) {
      throw Error("Could not verify rules signature");
    }
    log.debug(
      `Downloaded and verified rules for ${this.name}, now uncompressing`
    );
    this._makeMappings(JSON.parse(await gunzip(rulesGz)));
  }

  _makeMappings(rules) {
    const toTest = /^https?:\/\/[a-zA-Z0-9\.]{56}\.onion$/;
    const mappings = [];
    rules.rulesets.forEach(rule => {
      if (rule.rule.length != 1) {
        log.warn(`Unsupported rule lenght: ${rule.rule.length}`);
        return;
      }
      if (!toTest.test(rule.rule[0].to)) {
        log.warn(
          `Ignoring rule, because of a malformed to: ${rule.rule[0].to}`
        );
        return;
      }
      let toHostname;
      try {
        const toUrl = new URL(rule.rule[0].to);
        toHostname = toUrl.hostname;
      } catch (err) {
        log.error(
          "Cannot detect the hostname from the to rule",
          rule.rule[0].to,
          err
        );
      }
      let fromRe;
      try {
        fromRe = new RegExp(rule.rule[0].from);
      } catch (err) {
        log.error("Malformed from field", rule.rule[0].from, err);
        return;
      }
      for (const target of rule.target) {
        if (
          target.endsWith(".tor.onion") &&
          this.scope.test(`http://${target}/`) &&
          fromRe.test(`http://${target}/`)
        ) {
          mappings.push([target, toHostname]);
        } else {
          log.warn("Ignoring malformed rule", rule);
        }
      }
    });
    this.mappings = mappings;
    this.currentTimestamp = rules.timestamp;
    log.debug(`Updated mappings for ${this.name}`, mappings);
  }

  async updateMappings(force) {
    force = force === undefined ? false : !!force;
    if (!this._enabled && !force) {
      return;
    }
    await this.updateLatestTimestamp();
    if (this.latestTimestamp <= this.currentTimestamp && !force) {
      log.debug(
        `Rules for ${this.name} are already up to date, skipping update`
      );
      return;
    }
    await this.downloadVerifiedRules();
  }

  get enabled() {
    return this._enabled;
  }
  set enabled(enabled) {
    this._enabled = enabled;
    if (!enabled) {
      this.mappings = [];
      this.currentTimestamp = 0;
      this.latestTimestamp = 0;
    }
  }

  toJSON() {
    let scope = this.scope.toString();
    scope = scope.substr(1, scope.length - 2);
    return {
      name: this.name,
      pathPrefix: this.pathPrefix,
      jwk: this.jwk,
      scope,
      enabled: this._enabled,
      mappings: this.mappings,
      currentTimestamp: this.currentTimestamp,
    };
  }

  static fromJSON(obj) {
    let channel = new Channel(
      obj.name,
      obj.pathPrefix,
      obj.jwk,
      new RegExp(obj.scope),
      obj.enabled
    );
    if (obj.enabled) {
      channel.mappings = obj.mappings;
      channel.currentTimestamp = obj.currentTimestamp;
    }
    return channel;
  }
}

class _OnionAliasStore {
  static get RULESET_CHECK_INTERVAL() {
    return 86400 * 1000; // 1 day, like HTTPS-Everywhere
  }

  constructor() {
    this._channels = new Map();
    this._rulesetTimeout = null;
    this._lastCheck = 0;
    this._storage = null;
  }

  async init() {
    lazy.TorRequestWatch.start();
    await this._loadSettings();
    if (this.enabled) {
      await this._startUpdates();
    }
    Services.prefs.addObserver(kPrefOnionAliasEnabled, this);
  }

  uninit() {
    this._clear();
    if (this._rulesetTimeout) {
      clearTimeout(this._rulesetTimeout);
    }
    this._rulesetTimeout = null;
    Services.prefs.removeObserver(kPrefOnionAliasEnabled, this);
    lazy.TorRequestWatch.stop();
  }

  async getChannels() {
    if (this._storage === null) {
      await this._loadSettings();
    }
    return Array.from(this._channels.values(), ch => ch.toJSON());
  }

  async setChannel(chanData) {
    const name = chanData.name?.trim();
    if (!name) {
      throw Error("Name cannot be empty");
    }

    new URL(chanData.pathPrefix);
    const scope = new RegExp(chanData.scope);
    const ch = new Channel(
      name,
      chanData.pathPrefix,
      chanData.jwk,
      scope,
      !!chanData.enabled
    );
    // Call makeKey to make it throw if the key is invalid
    await ch.makeKey();
    this._channels.set(name, ch);
    this._applyMappings();
    this._saveSettings();
    setTimeout(this._notifyChanges.bind(this), 1);
    return ch;
  }

  enableChannel(name, enabled) {
    const channel = this._channels.get(name);
    if (channel !== null) {
      channel.enabled = enabled;
      this._applyMappings();
      this._saveSettings();
      this._notifyChanges();
      if (this.enabled && enabled && !channel.currentTimestamp) {
        this.updateChannel(name);
      }
    }
  }

  async updateChannel(name) {
    if (!this.enabled) {
      throw Error("Onion Aliases are disabled");
    }
    const channel = this._channels.get(name);
    if (channel === null) {
      throw Error("Channel not found");
    }
    await channel.updateMappings(true);
    this._saveSettings();
    this._applyMappings();
    setTimeout(this._notifyChanges.bind(this), 1);
    return channel;
  }

  deleteChannel(name) {
    if (this._channels.delete(name)) {
      this._saveSettings();
      this._applyMappings();
      this._notifyChanges();
    }
  }

  async _loadSettings() {
    if (this._storage !== null) {
      return;
    }
    this._channels = new Map();
    this._storage = new lazy.JSONFile({
      path: PathUtils.join(
        Services.dirsvc.get("ProfD", Ci.nsIFile).path,
        "onion-aliases.json"
      ),
      dataPostProcessor: this._settingsProcessor.bind(this),
    });
    await this._storage.load();
    log.debug("Loaded settings", this._storage.data, this._storage.path);
    this._applyMappings();
    this._notifyChanges();
  }

  _settingsProcessor(data) {
    if ("lastCheck" in data) {
      this._lastCheck = data.lastCheck;
    } else {
      data.lastCheck = 0;
    }
    if (!("channels" in data) || !Array.isArray(data.channels)) {
      data.channels = [SECURE_DROP];
      // Force updating
      data.lastCheck = 0;
    }
    const channels = new Map();
    data.channels = data.channels.filter(ch => {
      try {
        channels.set(ch.name, Channel.fromJSON(ch));
      } catch (err) {
        log.error("Could not load a channel", err, ch);
        return false;
      }
      return true;
    });
    this._channels = channels;
    return data;
  }

  _saveSettings() {
    if (this._storage === null) {
      throw Error("Settings have not been loaded");
    }
    this._storage.data.lastCheck = this._lastCheck;
    this._storage.data.channels = Array.from(this._channels.values(), ch =>
      ch.toJSON()
    );
    this._storage.saveSoon();
  }

  _addMapping(shortOnionHost, longOnionHost) {
    const service = Cc["@torproject.org/onion-alias-service;1"].getService(
      Ci.IOnionAliasService
    );
    service.addOnionAlias(shortOnionHost, longOnionHost);
  }

  _clear() {
    const service = Cc["@torproject.org/onion-alias-service;1"].getService(
      Ci.IOnionAliasService
    );
    service.clearOnionAliases();
  }

  _applyMappings() {
    this._clear();
    for (const ch of this._channels.values()) {
      if (!ch.enabled) {
        continue;
      }
      for (const [short, long] of ch.mappings) {
        this._addMapping(short, long);
      }
    }
  }

  async _periodicRulesetCheck() {
    if (!this.enabled) {
      log.debug("Onion Aliases are disabled, not updating rulesets.");
      return;
    }
    log.debug("Begin scheduled ruleset update");
    this._lastCheck = Date.now();
    let anyUpdated = false;
    for (const ch of this._channels.values()) {
      if (!ch.enabled) {
        log.debug(`Not updating ${ch.name} because not enabled`);
        continue;
      }
      log.debug(`Updating ${ch.name}`);
      try {
        await ch.updateMappings();
        anyUpdated = true;
      } catch (err) {
        log.error(`Could not update mappings for channel ${ch.name}`, err);
      }
    }
    if (anyUpdated) {
      this._saveSettings();
      this._applyMappings();
      this._notifyChanges();
    } else {
      log.debug("No channel has been updated, avoid saving");
    }
    this._scheduleCheck(_OnionAliasStore.RULESET_CHECK_INTERVAL);
  }

  async _startUpdates() {
    // This is a "private" function, so we expect the callers to verify wheter
    // onion aliases are enabled.
    // Callees will also do, so we avoid an additional check here.
    const dt = Date.now() - this._lastCheck;
    let force = false;
    for (const ch of this._channels.values()) {
      if (ch.enabled && !ch.currentTimestamp) {
        // Edited while being offline or some other error happened
        force = true;
        break;
      }
    }
    if (dt > _OnionAliasStore.RULESET_CHECK_INTERVAL || force) {
      log.debug(
        `Mappings are stale (${dt}), or force check requested (${force}), checking them immediately`
      );
      await this._periodicRulesetCheck();
    } else {
      this._scheduleCheck(_OnionAliasStore.RULESET_CHECK_INTERVAL - dt);
    }
  }

  _scheduleCheck(dt) {
    if (this._rulesetTimeout) {
      log.warn("The previous update timeout was not null");
      clearTimeout(this._rulesetTimeout);
    }
    if (!this.enabled) {
      log.warn(
        "Ignoring the scheduling of a new check because the Onion Alias feature is currently disabled."
      );
      this._rulesetTimeout = null;
      return;
    }
    log.debug(`Scheduling ruleset update in ${dt}`);
    this._rulesetTimeout = setTimeout(() => {
      this._rulesetTimeout = null;
      this._periodicRulesetCheck();
    }, dt);
  }

  _notifyChanges() {
    Services.obs.notifyObservers(
      Array.from(this._channels.values(), ch => ch.toJSON()),
      OnionAliasStoreTopics.ChannelsChanged
    );
  }

  get enabled() {
    return Services.prefs.getBoolPref(kPrefOnionAliasEnabled, true);
  }

  observe(aSubject, aTopic, aData) {
    if (aTopic === "nsPref:changed") {
      if (this.enabled) {
        this._startUpdates();
      } else if (this._rulesetTimeout) {
        clearTimeout(this._rulesetTimeout);
        this._rulesetTimeout = null;
      }
    }
  }
}

export const OnionAliasStore = new _OnionAliasStore();
Loading