tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

OnionAliasStore.sys.mjs (16127B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
     11  TorConnect: "resource://gre/modules/TorConnect.sys.mjs",
     12  TorConnectTopics: "resource://gre/modules/TorConnect.sys.mjs",
     13  TorRequestWatch:
     14    "moz-src:///browser/components/onionservices/TorRequestWatch.sys.mjs",
     15 });
     16 
     17 /* OnionAliasStore observer topics */
     18 export const OnionAliasStoreTopics = Object.freeze({
     19  ChannelsChanged: "onionaliasstore:channels-changed",
     20 });
     21 
     22 const SECURE_DROP = {
     23  name: "SecureDropTorOnion2021",
     24  pathPrefix: "https://securedrop.org/https-everywhere-2021/",
     25  jwk: {
     26    kty: "RSA",
     27    e: "AQAB",
     28    n: "vsC7BNafkRe8Uh1DUgCkv6RbPQMdJgAKKnWdSqQd7tQzU1mXfmo_k1Py_2MYMZXOWmqSZ9iwIYkykZYywJ2VyMGve4byj1sLn6YQoOkG8g5Z3V4y0S2RpEfmYumNjTzfq8nxtLnwjaYd4sCUd5wa0SzeLrpRQuXo2bF3QuUF2xcbLJloxX1MmlsMMCdBc-qGNonLJ7bpn_JuyXlDWy1Fkeyw1qgjiOdiRIbMC1x302zgzX6dSrBrNB8Cpsh-vCE0ZjUo8M9caEv06F6QbYmdGJHM0ZZY34OHMSNdf-_qUKIV_SuxuSuFE99tkAeWnbWpyI1V-xhVo1sc7NzChP8ci2TdPvI3_0JyAuCvL6zIFqJUJkZibEUghhg6F09-oNJKpy7rhUJq7zZyLXJsvuXnn0gnIxfjRvMcDfZAKUVMZKRdw7fwWzwQril4Ib0MQOVda9vb_4JMk7Gup-TUI4sfuS4NKwsnKoODIO-2U5QpJWdtp1F4AQ1pBv8ajFl1WTrVGvkRGK0woPWaO6pWyJ4kRnhnxrV2FyNNt3JSR-0JEjhFWws47kjBvpr0VRiVRFppKA-plKs4LPlaaCff39TleYmY3mETe3w1GIGc2Lliad32Jpbx496IgDe1K3FMBEoKFZfhmtlRSXft8NKgSzPt2zkatM9bFKfaCYRaSy7akbk",
     29  },
     30  scope: /^https?:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.securedrop\.tor\.onion\//,
     31  enabled: true,
     32  mappings: [],
     33  currentTimestamp: 0,
     34 };
     35 
     36 const kPrefOnionAliasEnabled = "browser.urlbar.onionRewrites.enabled";
     37 
     38 const log = console.createInstance({
     39  maxLogLevelPref: "browser.onionalias.log_level",
     40  prefix: "OnionAlias",
     41 });
     42 
     43 // Inspired by aboutMemory.js and PingCentre.jsm
     44 function gunzip(buffer) {
     45  return new Promise(resolve => {
     46    const listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
     47      Ci.nsIStreamLoader
     48    );
     49    listener.init({
     50      onStreamComplete(loader, context, status, length, result) {
     51        resolve(String.fromCharCode(...result));
     52      },
     53    });
     54    const scs = Cc["@mozilla.org/streamConverters;1"].getService(
     55      Ci.nsIStreamConverterService
     56    );
     57    const converter = scs.asyncConvertData(
     58      "gzip",
     59      "uncompressed",
     60      listener,
     61      null
     62    );
     63    const stream = Cc[
     64      "@mozilla.org/io/arraybuffer-input-stream;1"
     65    ].createInstance(Ci.nsIArrayBufferInputStream);
     66    stream.setData(buffer, 0, buffer.byteLength);
     67    converter.onStartRequest(null, null);
     68    converter.onDataAvailable(null, stream, 0, buffer.byteLength);
     69    converter.onStopRequest(null, null, null);
     70  });
     71 }
     72 
     73 /**
     74 * A channel that distributes Onion aliases.
     75 *
     76 * Each channel needs:
     77 *  - a name
     78 *  - a key used to sign the rules
     79 *  - a path prefix that will be used to build the URLs used to fetch updates
     80 *  - a scope (the apex domain for all aliases, and it must be a subdomain of
     81 *    .tor.onion).
     82 */
     83 class Channel {
     84  static get SIGN_ALGORITHM() {
     85    return {
     86      name: "RSA-PSS",
     87      saltLength: 32,
     88      hash: { name: "SHA-256" },
     89    };
     90  }
     91 
     92  #enabled;
     93 
     94  constructor(name, pathPrefix, jwk, scope, enabled) {
     95    this.name = name;
     96    this.pathPrefix = pathPrefix;
     97    this.jwk = jwk;
     98    this.scope = scope;
     99    this.#enabled = enabled;
    100 
    101    this.mappings = [];
    102    this.currentTimestamp = 0;
    103    this.latestTimestamp = 0;
    104  }
    105 
    106  async updateLatestTimestamp() {
    107    const timestampUrl = this.pathPrefix + "/latest-rulesets-timestamp";
    108    log.debug(`Updating ${this.name} timestamp from ${timestampUrl}`);
    109    const response = await fetch(timestampUrl);
    110    if (!response.ok) {
    111      throw Error(`Could not fetch timestamp for ${this.name}`, {
    112        cause: response.status,
    113      });
    114    }
    115    const timestampStr = await response.text();
    116    const timestamp = parseInt(timestampStr);
    117    // Avoid hijacking, sanitize the timestamp
    118    if (isNaN(timestamp)) {
    119      throw Error("Latest timestamp is not a number");
    120    }
    121    log.debug(`Updated ${this.name} timestamp: ${timestamp}`);
    122    this.latestTimestamp = timestamp;
    123  }
    124 
    125  async makeKey() {
    126    return crypto.subtle.importKey(
    127      "jwk",
    128      this.jwk,
    129      Channel.SIGN_ALGORITHM,
    130      false,
    131      ["verify"]
    132    );
    133  }
    134 
    135  async downloadVerifiedRules() {
    136    log.debug(`Downloading and verifying ruleset for ${this.name}`);
    137 
    138    const key = await this.makeKey();
    139    const signatureUrl =
    140      this.pathPrefix + `/rulesets-signature.${this.latestTimestamp}.sha256`;
    141    const signatureResponse = await fetch(signatureUrl);
    142    if (!signatureResponse.ok) {
    143      throw Error("Could not fetch the rules signature");
    144    }
    145    const signature = await signatureResponse.arrayBuffer();
    146 
    147    const rulesUrl =
    148      this.pathPrefix + `/default.rulesets.${this.latestTimestamp}.gz`;
    149    const rulesResponse = await fetch(rulesUrl);
    150    if (!rulesResponse.ok) {
    151      throw Error("Could not fetch rules");
    152    }
    153    const rulesGz = await rulesResponse.arrayBuffer();
    154 
    155    if (
    156      !(await crypto.subtle.verify(
    157        Channel.SIGN_ALGORITHM,
    158        key,
    159        signature,
    160        rulesGz
    161      ))
    162    ) {
    163      throw Error("Could not verify rules signature");
    164    }
    165    log.debug(
    166      `Downloaded and verified rules for ${this.name}, now uncompressing`
    167    );
    168    this.#makeMappings(JSON.parse(await gunzip(rulesGz)));
    169  }
    170 
    171  #makeMappings(rules) {
    172    const toTest = /^https?:\/\/[a-zA-Z0-9\.]{56}\.onion$/;
    173    const mappings = [];
    174    rules.rulesets.forEach(rule => {
    175      if (rule.rule.length != 1) {
    176        log.warn(`Unsupported rule lenght: ${rule.rule.length}`);
    177        return;
    178      }
    179      if (!toTest.test(rule.rule[0].to)) {
    180        log.warn(
    181          `Ignoring rule, because of a malformed to: ${rule.rule[0].to}`
    182        );
    183        return;
    184      }
    185      const toHostname = URL.parse(rule.rule[0].to)?.hostname;
    186      if (!toHostname) {
    187        log.error(
    188          "Unable to parse the URL and the hostname from the to rule",
    189          rule.rule[0].to
    190        );
    191        return;
    192      }
    193 
    194      let fromRe;
    195      try {
    196        fromRe = new RegExp(rule.rule[0].from);
    197      } catch (err) {
    198        log.error("Malformed from field", rule.rule[0].from, err);
    199        return;
    200      }
    201      for (const target of rule.target) {
    202        if (
    203          target.endsWith(".tor.onion") &&
    204          this.scope.test(`http://${target}/`) &&
    205          fromRe.test(`http://${target}/`)
    206        ) {
    207          mappings.push([target, toHostname]);
    208        } else {
    209          log.warn("Ignoring malformed rule", rule);
    210        }
    211      }
    212    });
    213    this.mappings = mappings;
    214    this.currentTimestamp = rules.timestamp;
    215    log.debug(`Updated mappings for ${this.name}`, mappings);
    216  }
    217 
    218  async updateMappings(force) {
    219    force = force === undefined ? false : !!force;
    220    if (!this.#enabled && !force) {
    221      return;
    222    }
    223    await this.updateLatestTimestamp();
    224    if (this.latestTimestamp <= this.currentTimestamp && !force) {
    225      log.debug(
    226        `Rules for ${this.name} are already up to date, skipping update`
    227      );
    228      return;
    229    }
    230    await this.downloadVerifiedRules();
    231  }
    232 
    233  get enabled() {
    234    return this.#enabled;
    235  }
    236  set enabled(enabled) {
    237    this.#enabled = enabled;
    238    if (!enabled) {
    239      this.mappings = [];
    240      this.currentTimestamp = 0;
    241      this.latestTimestamp = 0;
    242    }
    243  }
    244 
    245  toJSON() {
    246    let scope = this.scope.toString();
    247    scope = scope.substr(1, scope.length - 2);
    248    return {
    249      name: this.name,
    250      pathPrefix: this.pathPrefix,
    251      jwk: this.jwk,
    252      scope,
    253      enabled: this.#enabled,
    254      mappings: this.mappings,
    255      currentTimestamp: this.currentTimestamp,
    256    };
    257  }
    258 
    259  static fromJSON(obj) {
    260    let channel = new Channel(
    261      obj.name,
    262      obj.pathPrefix,
    263      obj.jwk,
    264      new RegExp(obj.scope),
    265      obj.enabled
    266    );
    267    if (obj.enabled) {
    268      channel.mappings = obj.mappings;
    269      channel.currentTimestamp = obj.currentTimestamp;
    270    }
    271    return channel;
    272  }
    273 }
    274 
    275 /**
    276 * The manager of onion aliases.
    277 * It allows creating, reading, updating and deleting channels and it keeps them
    278 * updated.
    279 *
    280 * This class is a singleton which should be accessed with OnionAliasStore.
    281 */
    282 class _OnionAliasStore {
    283  static get RULESET_CHECK_INTERVAL() {
    284    return 86400 * 1000; // 1 day, like HTTPS-Everywhere
    285  }
    286 
    287  #channels = new Map();
    288  #rulesetTimeout = null;
    289  #lastCheck = 0;
    290  #storage = null;
    291 
    292  async init() {
    293    lazy.TorRequestWatch.start();
    294    await this.#loadSettings();
    295    if (this.enabled && !lazy.TorConnect.shouldShowTorConnect) {
    296      await this.#startUpdates();
    297    } else {
    298      Services.obs.addObserver(this, lazy.TorConnectTopics.BootstrapComplete);
    299    }
    300    Services.prefs.addObserver(kPrefOnionAliasEnabled, this);
    301  }
    302 
    303  uninit() {
    304    this.#clear();
    305    if (this.#rulesetTimeout) {
    306      clearTimeout(this.#rulesetTimeout);
    307    }
    308    this.#rulesetTimeout = null;
    309 
    310    Services.obs.removeObserver(this, lazy.TorConnectTopics.BootstrapComplete);
    311    Services.prefs.removeObserver(kPrefOnionAliasEnabled, this);
    312 
    313    lazy.TorRequestWatch.stop();
    314  }
    315 
    316  async getChannels() {
    317    if (this.#storage === null) {
    318      await this.#loadSettings();
    319    }
    320    return Array.from(this.#channels.values(), ch => ch.toJSON());
    321  }
    322 
    323  async setChannel(chanData) {
    324    const name = chanData.name?.trim();
    325    if (!name) {
    326      throw Error("Name cannot be empty");
    327    }
    328 
    329    // This will throw if the URL is invalid.
    330    new URL(chanData.pathPrefix);
    331    const scope = new RegExp(chanData.scope);
    332    const ch = new Channel(
    333      name,
    334      chanData.pathPrefix,
    335      chanData.jwk,
    336      scope,
    337      !!chanData.enabled
    338    );
    339    // Call makeKey to make it throw if the key is invalid
    340    await ch.makeKey();
    341    this.#channels.set(name, ch);
    342    this.#applyMappings();
    343    this.#saveSettings();
    344    setTimeout(this.#notifyChanges.bind(this), 1);
    345    return ch;
    346  }
    347 
    348  enableChannel(name, enabled) {
    349    const channel = this.#channels.get(name);
    350    if (channel !== null) {
    351      channel.enabled = enabled;
    352      this.#applyMappings();
    353      this.#saveSettings();
    354      this.#notifyChanges();
    355      if (this.enabled && enabled && !channel.currentTimestamp) {
    356        this.updateChannel(name);
    357      }
    358    }
    359  }
    360 
    361  async updateChannel(name) {
    362    if (!this.enabled) {
    363      throw Error("Onion Aliases are disabled");
    364    }
    365    const channel = this.#channels.get(name);
    366    if (channel === null) {
    367      throw Error("Channel not found");
    368    }
    369    await channel.updateMappings(true);
    370    this.#saveSettings();
    371    this.#applyMappings();
    372    setTimeout(this.#notifyChanges.bind(this), 1);
    373    return channel;
    374  }
    375 
    376  deleteChannel(name) {
    377    if (this.#channels.delete(name)) {
    378      this.#saveSettings();
    379      this.#applyMappings();
    380      this.#notifyChanges();
    381    }
    382  }
    383 
    384  async #loadSettings() {
    385    if (this.#storage !== null) {
    386      return;
    387    }
    388    this.#channels = new Map();
    389    this.#storage = new lazy.JSONFile({
    390      path: PathUtils.join(
    391        Services.dirsvc.get("ProfD", Ci.nsIFile).path,
    392        "onion-aliases.json"
    393      ),
    394      dataPostProcessor: this.#settingsProcessor.bind(this),
    395    });
    396    await this.#storage.load();
    397    log.debug("Loaded settings", this.#storage.data, this.#storage.path);
    398    this.#applyMappings();
    399    this.#notifyChanges();
    400  }
    401 
    402  #settingsProcessor(data) {
    403    if ("lastCheck" in data) {
    404      this.#lastCheck = data.lastCheck;
    405    } else {
    406      data.lastCheck = 0;
    407    }
    408    if (!("channels" in data) || !Array.isArray(data.channels)) {
    409      data.channels = [SECURE_DROP];
    410      // Force updating
    411      data.lastCheck = 0;
    412    }
    413    const channels = new Map();
    414    data.channels = data.channels.filter(ch => {
    415      try {
    416        channels.set(ch.name, Channel.fromJSON(ch));
    417      } catch (err) {
    418        log.error("Could not load a channel", err, ch);
    419        return false;
    420      }
    421      return true;
    422    });
    423    this.#channels = channels;
    424    return data;
    425  }
    426 
    427  #saveSettings() {
    428    if (this.#storage === null) {
    429      throw Error("Settings have not been loaded");
    430    }
    431    this.#storage.data.lastCheck = this.#lastCheck;
    432    this.#storage.data.channels = Array.from(this.#channels.values(), ch =>
    433      ch.toJSON()
    434    );
    435    this.#storage.saveSoon();
    436  }
    437 
    438  #addMapping(shortOnionHost, longOnionHost) {
    439    const service = Cc["@torproject.org/onion-alias-service;1"].getService(
    440      Ci.IOnionAliasService
    441    );
    442    service.addOnionAlias(shortOnionHost, longOnionHost);
    443  }
    444 
    445  #clear() {
    446    const service = Cc["@torproject.org/onion-alias-service;1"].getService(
    447      Ci.IOnionAliasService
    448    );
    449    service.clearOnionAliases();
    450  }
    451 
    452  #applyMappings() {
    453    this.#clear();
    454    for (const ch of this.#channels.values()) {
    455      if (!ch.enabled) {
    456        continue;
    457      }
    458      for (const [short, long] of ch.mappings) {
    459        this.#addMapping(short, long);
    460      }
    461    }
    462  }
    463 
    464  async #periodicRulesetCheck() {
    465    if (!this.enabled) {
    466      log.debug("Onion Aliases are disabled, not updating rulesets.");
    467      return;
    468    }
    469    log.debug("Begin scheduled ruleset update");
    470    this.#lastCheck = Date.now();
    471    let anyUpdated = false;
    472    for (const ch of this.#channels.values()) {
    473      if (!ch.enabled) {
    474        log.debug(`Not updating ${ch.name} because not enabled`);
    475        continue;
    476      }
    477      log.debug(`Updating ${ch.name}`);
    478      try {
    479        await ch.updateMappings();
    480        anyUpdated = true;
    481      } catch (err) {
    482        log.error(`Could not update mappings for channel ${ch.name}`, err);
    483      }
    484    }
    485    if (anyUpdated) {
    486      this.#saveSettings();
    487      this.#applyMappings();
    488      this.#notifyChanges();
    489    } else {
    490      log.debug("No channel has been updated, avoid saving");
    491    }
    492    this.#scheduleCheck(_OnionAliasStore.RULESET_CHECK_INTERVAL);
    493  }
    494 
    495  async #startUpdates() {
    496    // This is a private function, so we expect the callers to verify whether
    497    // onion aliases are enabled.
    498    // Callees will also do, so we avoid an additional check here.
    499    const dt = Date.now() - this.#lastCheck;
    500    let force = false;
    501    for (const ch of this.#channels.values()) {
    502      if (ch.enabled && !ch.currentTimestamp) {
    503        // Edited while being offline or some other error happened
    504        force = true;
    505        break;
    506      }
    507    }
    508    if (dt > _OnionAliasStore.RULESET_CHECK_INTERVAL || force) {
    509      log.debug(
    510        `Mappings are stale (${dt}), or force check requested (${force}), checking them immediately`
    511      );
    512      await this.#periodicRulesetCheck();
    513    } else {
    514      this.#scheduleCheck(_OnionAliasStore.RULESET_CHECK_INTERVAL - dt);
    515    }
    516  }
    517 
    518  #scheduleCheck(dt) {
    519    if (this.#rulesetTimeout) {
    520      log.warn("The previous update timeout was not null");
    521      clearTimeout(this.#rulesetTimeout);
    522    }
    523    if (!this.enabled) {
    524      log.warn(
    525        "Ignoring the scheduling of a new check because the Onion Alias feature is currently disabled."
    526      );
    527      this.#rulesetTimeout = null;
    528      return;
    529    }
    530    log.debug(`Scheduling ruleset update in ${dt}`);
    531    this.#rulesetTimeout = setTimeout(() => {
    532      this.#rulesetTimeout = null;
    533      this.#periodicRulesetCheck();
    534    }, dt);
    535  }
    536 
    537  #notifyChanges() {
    538    Services.obs.notifyObservers(
    539      Array.from(this.#channels.values(), ch => ch.toJSON()),
    540      OnionAliasStoreTopics.ChannelsChanged
    541    );
    542  }
    543 
    544  get enabled() {
    545    return Services.prefs.getBoolPref(kPrefOnionAliasEnabled, true);
    546  }
    547 
    548  observe(aSubject, aTopic) {
    549    if (aTopic === "nsPref:changed") {
    550      if (this.enabled) {
    551        this.#startUpdates();
    552      } else if (this.#rulesetTimeout) {
    553        clearTimeout(this.#rulesetTimeout);
    554        this.#rulesetTimeout = null;
    555      }
    556    } else if (
    557      aTopic === lazy.TorConnectTopics.BootstrapComplete &&
    558      this.enabled
    559    ) {
    560      this.#startUpdates();
    561    }
    562  }
    563 }
    564 
    565 export const OnionAliasStore = new _OnionAliasStore();