tor-browser

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

GuardianClient.sys.mjs (19686B)


      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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () =>
     10  ChromeUtils.importESModule(
     11    "resource://gre/modules/FxAccounts.sys.mjs"
     12  ).getFxAccountsSingleton()
     13 );
     14 ChromeUtils.defineLazyGetter(
     15  lazy,
     16  "hiddenBrowserManager",
     17  () =>
     18    ChromeUtils.importESModule("resource://gre/modules/HiddenFrame.sys.mjs")
     19      .HiddenBrowserManager
     20 );
     21 ChromeUtils.defineLazyGetter(
     22  lazy,
     23  "JsonSchemaValidator",
     24  () =>
     25    ChromeUtils.importESModule(
     26      "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs"
     27    ).JsonSchemaValidator
     28 );
     29 
     30 if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) {
     31  throw new Error("Guardian.sys.mjs should only run in the parent process");
     32 }
     33 
     34 /**
     35 * An HTTP Client to talk to the Guardian service.
     36 * Allows to enroll FxA users to the proxy service,
     37 * fetch a proxy pass and check if the user is a proxy user.
     38 *
     39 */
     40 export class GuardianClient {
     41  /**
     42   * @param {typeof gConfig} [config]
     43   */
     44  constructor(config = gConfig) {
     45    this.guardianEndpoint = config.guardianEndpoint;
     46    this.fxaOrigin = config.fxaOrigin;
     47    this.withToken = config.withToken;
     48  }
     49  /**
     50   * Checks the current user's FxA account to see if it is linked to the Guardian service.
     51   * This should be used before attempting to check Entitlement info.
     52   *
     53   * @param { boolean } onlyCached - if true only the cached clients will be checked.
     54   * @returns {Promise<boolean>}
     55   *  - True: The user is linked to the Guardian service, they might be a proxy user or have/had a VPN-Subscription.
     56   *          This needs to be followed up with a call to `fetchUserInfo()` to check if they are a proxy user.
     57   *  - False: The user is not linked to the Guardian service, they cannot be a proxy user.
     58   */
     59  async isLinkedToGuardian(onlyCached = false) {
     60    const guardian_clientId = CLIENT_ID_MAP[this.#successURL.origin];
     61    if (!guardian_clientId) {
     62      // If we end up using an unknown successURL, we are definitely not linked to Guardian.
     63      return false;
     64    }
     65 
     66    const cached_clients = await lazy.fxAccounts.listAttachedOAuthClients();
     67    if (cached_clients.some(client => client.id === guardian_clientId)) {
     68      return true;
     69    }
     70    if (onlyCached) {
     71      return false;
     72    }
     73    // If we don't have the client in the cache, we refresh it, just to be sure.
     74    const refreshed_clients =
     75      await lazy.fxAccounts.listAttachedOAuthClients(true);
     76    if (refreshed_clients.some(client => client.id === guardian_clientId)) {
     77      return true;
     78    }
     79    return false;
     80  }
     81 
     82  /**
     83   * Tries to enroll the user to the proxy service.
     84   * It will silently try to sign in the user into guardian using their FxA account.
     85   * If the user already has a proxy entitlement, the experiment type will update.
     86   *
     87   * @param { "alpha" | "beta" | "delta" | "gamma" } aExperimentType - The experiment type to enroll the user into.
     88   * The experiment type controls which feature set the user will get in Firefox.
     89   *
     90   * @param { AbortSignal | null } aAbortSignal - An AbortSignal to cancel the operation.
     91   * @returns {Promise<{error?: string, ok?: boolean}>}
     92   */
     93  async enroll(aExperimentType = "alpha", aAbortSignal = null) {
     94    // We abort loading the page if the origion is not allowed.
     95    const allowedOrigins = [
     96      new URL(this.guardianEndpoint).origin,
     97      new URL(this.fxaOrigin).origin,
     98    ];
     99    // If the browser is redirected to one of those urls
    100    // we know we're done with the browser.
    101    const finalizerURLs = [this.#successURL, this.#enrollmentError];
    102    return await lazy.hiddenBrowserManager.withHiddenBrowser(async browser => {
    103      aAbortSignal?.addEventListener("abort", () => {
    104        browser.stop();
    105        browser.remove();
    106        throw new Error("aborted");
    107      });
    108      const finalEndpoint = waitUntilURL(browser, url => {
    109        const urlObj = new URL(url);
    110        if (url === "about:blank") {
    111          return false;
    112        }
    113        if (!allowedOrigins.includes(urlObj.origin)) {
    114          browser.stop();
    115          browser.remove();
    116          throw new Error(
    117            `URL ${url} with origin ${urlObj.origin} is not allowed.`
    118          );
    119        }
    120        if (
    121          finalizerURLs.some(
    122            finalizer =>
    123              urlObj.pathname === finalizer.pathname &&
    124              urlObj.origin === finalizer.origin
    125          )
    126        ) {
    127          return true;
    128        }
    129        return false;
    130      });
    131      const loginURL = this.#loginURL;
    132      loginURL.searchParams.set("experiment", aExperimentType);
    133      browser.loadURI(Services.io.newURI(loginURL.href), {
    134        // TODO: Make sure this is the right principal to use?
    135        triggeringPrincipal:
    136          Services.scriptSecurityManager.getSystemPrincipal(),
    137      });
    138 
    139      const result = await finalEndpoint;
    140      return GuardianClient._parseGuardianSuccessURL(result);
    141    });
    142  }
    143 
    144  static _parseGuardianSuccessURL(aUrl) {
    145    if (!aUrl) {
    146      return { error: "timeout", ok: false };
    147    }
    148    const url = new URL(aUrl);
    149    const params = new URLSearchParams(url.search);
    150    const error = params.get("error");
    151    if (error) {
    152      return { error, ok: false };
    153    }
    154    // Otherwise we should have:
    155    // - a code in the URL query
    156    if (!params.has("code")) {
    157      return { error: "missing_code", ok: false };
    158    }
    159    return { ok: true };
    160  }
    161 
    162  /**
    163   * Fetches a proxy pass from the Guardian service.
    164   *
    165   * @returns {Promise<{error?: string, status?:number, pass?: ProxyPass}>} Resolves with an object containing either an error string or the proxy pass data and a status code.
    166   * Status codes to watch for:
    167   * - 200: User is a proxy user and a new pass was fetched
    168   * - 403: The FxA was valid but the user is not a proxy user.
    169   * - 401: The FxA token was rejected.
    170   * - 5xx: Internal guardian error.
    171   */
    172  async fetchProxyPass() {
    173    const response = await this.withToken(async token => {
    174      return await fetch(this.#tokenURL, {
    175        method: "GET",
    176        cache: "no-cache",
    177        headers: {
    178          Authorization: `Bearer ${token}`,
    179          "Content-Type": "application/json",
    180        },
    181      });
    182    });
    183    if (!response) {
    184      return { error: "login_needed" };
    185    }
    186    const status = response.status;
    187    try {
    188      const pass = await ProxyPass.fromResponse(response);
    189      if (!pass) {
    190        return { status, error: "invalid_response" };
    191      }
    192      return { pass, status };
    193    } catch (error) {
    194      console.error("Error creating ProxyPass:", error);
    195      return { status, error: "parse_error" };
    196    }
    197  }
    198  /**
    199   * Fetches the user's entitlement information.
    200   *
    201   * @returns {Promise<{status?: number, entitlement?: Entitlement|null, error?:string}>} A promise that resolves to an object containing the HTTP status code and the user's entitlement information.
    202   *
    203   * Status codes to watch for:
    204   * - 200: User is a proxy user and the entitlement information is available.
    205   * - 404: User is not a proxy user, no entitlement information available.
    206   * - 401: The FxA token was rejected, probably guardian and fxa mismatch. (i.e guardian-stage and fxa-prod)
    207   */
    208  async fetchUserInfo() {
    209    const response = await this.withToken(async token => {
    210      return fetch(this.#statusURL, {
    211        method: "GET",
    212        headers: {
    213          Authorization: `Bearer ${token}`,
    214          "Content-Type": "application/json",
    215        },
    216        cache: "no-cache",
    217      });
    218    });
    219    if (!response) {
    220      return { error: "login_needed" };
    221    }
    222    const status = response.status;
    223    try {
    224      const entitlement = await Entitlement.fromResponse(response);
    225      if (!entitlement) {
    226        return { status, error: "parse_error" };
    227      }
    228      return {
    229        status,
    230        entitlement,
    231      };
    232    } catch (error) {
    233      return { status, error: "parse_error" };
    234    }
    235  }
    236 
    237  /** This is the URL that will be used to fetch the proxy pass. */
    238  get #tokenURL() {
    239    const url = new URL(this.guardianEndpoint);
    240    url.pathname = "/api/v1/fpn/token";
    241    return url;
    242  }
    243  /** This is the URL that will be used to log in to the Guardian service. */
    244  get #loginURL() {
    245    const url = new URL(this.guardianEndpoint);
    246    url.pathname = "/api/v1/fpn/auth";
    247    return url;
    248  }
    249  /** This is the URL that the user will be redirected to after a successful enrollment. */
    250  get #successURL() {
    251    const url = new URL(this.guardianEndpoint);
    252    url.pathname = "/oauth/success";
    253    return url;
    254  }
    255  /**
    256   * This is the URL that the user will be redirected to after a rejected/failed enrollment.
    257   * The url will contain an error query parameter with the error message.
    258   */
    259  get #enrollmentError() {
    260    const url = new URL(this.guardianEndpoint);
    261    url.pathname = "/api/v1/fpn/error";
    262    return url;
    263  }
    264  /** This is the URL that will be used to check the user's proxy status. */
    265  get #statusURL() {
    266    const url = new URL(this.guardianEndpoint);
    267    url.pathname = "/api/v1/fpn/status";
    268    return url;
    269  }
    270  guardianEndpoint = "";
    271 }
    272 
    273 /**
    274 * A ProxyPass contains a JWT token that can be used to authenticate the proxy service.
    275 * It also contains the timestamp until which the token is valid.
    276 * The Proxy will reject new connections if the token is not valid anymore.
    277 *
    278 * Immutable after creation.
    279 */
    280 export class ProxyPass extends EventTarget {
    281  #body = {
    282    /** Not Before */
    283    nbf: 0,
    284    /** Expiration */
    285    exp: 0,
    286  };
    287  /**
    288   * @param {string} token - The JWT to use for authentication.
    289   */
    290  constructor(token) {
    291    super();
    292    if (typeof token !== "string") {
    293      throw new TypeError(
    294        "Invalid arguments for ProxyPass constructor, token is not a string"
    295      );
    296    }
    297    this.token = token;
    298    // Contains [header.body.signature]
    299    const parts = this.token.split(".");
    300    if (parts.length !== 3) {
    301      throw new TypeError("Invalid token format");
    302    }
    303    try {
    304      const body = JSON.parse(atob(parts[1]));
    305      if (
    306        !lazy.JsonSchemaValidator.validate(body, ProxyPass.bodySchema).valid
    307      ) {
    308        throw new TypeError("Token body does not match schema");
    309      }
    310      this.#body = body;
    311    } catch (error) {
    312      throw new TypeError("Invalid token format: " + error.message);
    313    }
    314  }
    315 
    316  isValid(now = Temporal.Now.instant()) {
    317    // If the remaining duration is zero or positive, the pass is still valid.
    318    return (
    319      Temporal.Instant.compare(now, this.from) >= 0 &&
    320      Temporal.Instant.compare(now, this.until) < 0
    321    );
    322  }
    323 
    324  shouldRotate(now = Temporal.Now.instant()) {
    325    if (!this.isValid(now)) {
    326      return true;
    327    }
    328    return Temporal.Instant.compare(now, this.rotationTimePoint) >= 0;
    329  }
    330 
    331  get from() {
    332    // nbf is in seconds since epoch
    333    return Temporal.Instant.fromEpochMilliseconds(this.#body.nbf * 1000);
    334  }
    335 
    336  get until() {
    337    // exp is in seconds since epoch
    338    return Temporal.Instant.fromEpochMilliseconds(this.#body.exp * 1000);
    339  }
    340 
    341  /**
    342   * Parses a ProxyPass from a Response object.
    343   *
    344   * @param {Response} response
    345   * @returns {Promise<ProxyPass|null>} A promise that resolves to a ProxyPass instance or null if the response is invalid.
    346   */
    347  static async fromResponse(response) {
    348    // if the response is not 200 return null
    349    if (!response.ok) {
    350      console.error(
    351        `Failed to fetch proxy pass: ${response.status} ${response.statusText}`
    352      );
    353      return null;
    354    }
    355 
    356    try {
    357      // Parse JSON response
    358      const responseData = await response.json();
    359      const token = responseData?.token;
    360 
    361      if (!token || typeof token !== "string") {
    362        console.error("Missing or invalid token in response");
    363        return null;
    364      }
    365      return new ProxyPass(token);
    366    } catch (error) {
    367      console.error("Error parsing proxy pass response:", error);
    368      return null;
    369    }
    370  }
    371  /**
    372   * @type {Temporal.Instant} - The Point in time when the token should be rotated.
    373   */
    374  get rotationTimePoint() {
    375    return this.until.subtract(ProxyPass.ROTATION_TIME);
    376  }
    377 
    378  asBearerToken() {
    379    return `Bearer ${this.token}`;
    380  }
    381  // Rotate 10 Minutes from the End Time
    382  static ROTATION_TIME = Temporal.Duration.from({ minutes: 10 });
    383 
    384  static get bodySchema() {
    385    return {
    386      $schema: "http://json-schema.org/draft-07/schema#",
    387      title: "JWT Claims",
    388      type: "object",
    389      properties: {
    390        sub: {
    391          type: "string",
    392          description: "Subject identifier",
    393        },
    394        aud: {
    395          type: "string",
    396          format: "uri",
    397          description: "Audience for which the token is intended",
    398        },
    399        iat: {
    400          type: "integer",
    401          description: "Issued-at time (seconds since Unix epoch)",
    402        },
    403        nbf: {
    404          type: "integer",
    405          description: "Not-before time (seconds since Unix epoch)",
    406        },
    407        exp: {
    408          type: "integer",
    409          description: "Expiration time (seconds since Unix epoch)",
    410        },
    411        iss: {
    412          type: "string",
    413          description: "Issuer identifier",
    414        },
    415      },
    416      required: ["sub", "aud", "iat", "nbf", "exp", "iss"],
    417      additionalProperties: true,
    418    };
    419  }
    420 }
    421 
    422 /**
    423 * Represents a user's Entitlement for the Proxy Service of Guardian.
    424 *
    425 * Right now any FxA user can have one entitlement.
    426 * If a user has an entitlement, they may access the proxy service.
    427 *
    428 * Immutable after creation.
    429 */
    430 export class Entitlement {
    431  /** True if the User may Use the Autostart feature  */
    432  autostart = false;
    433  /** The date the entitlement was added to the user */
    434  created_at = new Date();
    435  /** True if the User has a limited bandwidth */
    436  limited_bandwidth = false;
    437  /** True if the User may Use the location controls */
    438  location_controls = false;
    439  /** True if the User has any valid subscription plan to the Mozilla VPN (not firefox VPN) */
    440  subscribed = false;
    441  /** The Guardian User ID */
    442  uid = 0;
    443  /** True if the User has website inclusion */
    444  website_inclusion = false;
    445 
    446  constructor(
    447    args = {
    448      autostart: false,
    449      created_at: new Date().toISOString(),
    450      limited_bandwidth: false,
    451      location_controls: false,
    452      subscribed: false,
    453      uid: 0,
    454      website_inclusion: false,
    455    }
    456  ) {
    457    // Ensure it parses to a valid date
    458    const parsed = Date.parse(args.created_at);
    459    if (isNaN(parsed)) {
    460      throw new TypeError("entitlementDate is not a valid date string");
    461    }
    462    this.autostart = args.autostart;
    463    this.limited_bandwidth = args.limited_bandwidth;
    464    this.location_controls = args.location_controls;
    465    this.website_inclusion = args.website_inclusion;
    466    this.subscribed = args.subscribed;
    467    this.uid = args.uid;
    468    this.created_at = parsed;
    469    Object.freeze(this);
    470  }
    471  static fromResponse(response) {
    472    // if the response is not 200 return null
    473    if (!response.ok) {
    474      return null;
    475    }
    476    return response.json().then(data => {
    477      const result = lazy.JsonSchemaValidator.validate(
    478        data,
    479        Entitlement.schema
    480      );
    481      if (!result.valid) {
    482        return null;
    483      }
    484      return new Entitlement(data);
    485    });
    486  }
    487 
    488  static get schema() {
    489    return {
    490      $schema: "http://json-schema.org/draft-07/schema#",
    491      title: "Entitlement",
    492      type: "object",
    493      properties: {
    494        autostart: {
    495          type: "boolean",
    496          description: "True if the User may Use the Autostart feature",
    497        },
    498        created_at: {
    499          type: "string",
    500          description: "The date the entitlement was added to the user",
    501          format: "date-time", // ISO 8601
    502          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$",
    503        },
    504        limited_bandwidth: {
    505          type: "boolean",
    506        },
    507        location_controls: {
    508          type: "boolean",
    509        },
    510        subscribed: {
    511          type: "boolean",
    512        },
    513        uid: {
    514          type: "integer",
    515        },
    516        website_inclusion: {
    517          type: "boolean",
    518        },
    519      },
    520      required: [
    521        "autostart",
    522        "created_at",
    523        "limited_bandwidth",
    524        "location_controls",
    525        "subscribed",
    526        "uid",
    527        "website_inclusion",
    528      ],
    529      additionalProperties: true,
    530    };
    531  }
    532 }
    533 
    534 /**
    535 * Maps the Guardian service endpoint to the public OAuth client ID.
    536 */
    537 const CLIENT_ID_MAP = {
    538  "http://localhost:3000": "6089c54fdc970aed",
    539  "https://guardian-dev.herokuapp.com": "64ef9b544a31bca8",
    540  "https://stage.guardian.nonprod.cloudops.mozgcp.net": "e6eb0d1e856335fc",
    541  "https://fpn.firefox.com": "e6eb0d1e856335fc",
    542  "https://vpn.mozilla.org": "e6eb0d1e856335fc",
    543 };
    544 
    545 /**
    546 * Adds a strong reference to keep listeners alive until
    547 * we're done with it.
    548 * (From kungFuDeathGrip in XPCShellContentUtils.sys.mjs)
    549 */
    550 const listeners = new Set();
    551 
    552 /**
    553 * Waits for a specific URL to be loaded in the browser.
    554 *
    555 * @param {*} browser - The browser instance to listen for URL changes.
    556 * @param {(location: string) => boolean} predicate - A function that returns true if the location matches the desired URL.
    557 * @returns {Promise<string>} A promise that resolves to the matching URL.
    558 */
    559 async function waitUntilURL(browser, predicate) {
    560  const prom = Promise.withResolvers();
    561  const done = false;
    562  const check = arg => {
    563    if (done) {
    564      return;
    565    }
    566    if (predicate(arg)) {
    567      listeners.delete(listener);
    568      browser.removeProgressListener(listener);
    569      prom.resolve(arg);
    570    }
    571  };
    572  const listener = {
    573    QueryInterface: ChromeUtils.generateQI([
    574      "nsIWebProgressListener",
    575      "nsISupportsWeakReference",
    576    ]),
    577 
    578    // Runs the check after the document has stopped loading.
    579    onStateChange(webProgress, request, stateFlags, status) {
    580      request.QueryInterface(Ci.nsIChannel);
    581 
    582      if (
    583        webProgress.isTopLevel &&
    584        stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
    585        status !== Cr.NS_BINDING_ABORTED
    586      ) {
    587        check(request.URI?.spec);
    588      }
    589    },
    590 
    591    // Unused callbacks we still need to implement:
    592    onLocationChange() {},
    593    onProgressChange() {},
    594    onStatusChange(_, request, status) {
    595      if (Components.isSuccessCode(status)) {
    596        return;
    597      }
    598      try {
    599        const url = request.QueryInterface(Ci.nsIChannel).URI.spec;
    600        check(url);
    601      } catch (ex) {}
    602    },
    603    onSecurityChange() {},
    604    onContentBlockingEvent() {},
    605  };
    606  listeners.add(listener);
    607  browser.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW);
    608  const url = await prom.promise;
    609  return url;
    610 }
    611 
    612 let gConfig = {
    613  /**
    614   * Executes the callback with an FxA token and returns its result.
    615   * Destroys the token after use.
    616   *
    617   * @template T
    618   * @param {(token: string) => T|Promise<T>} cb
    619   * @returns {Promise<T|null>}
    620   */
    621  withToken: async cb => {
    622    const token = await lazy.fxAccounts.getOAuthToken({
    623      scope: ["profile", "https://identity.mozilla.com/apps/vpn"],
    624    });
    625    if (!token) {
    626      return null;
    627    }
    628    const res = await cb(token);
    629    lazy.fxAccounts.removeCachedOAuthToken({
    630      token,
    631    });
    632    return res;
    633  },
    634  guardianEndpoint: "",
    635  fxaOrigin: "",
    636 };
    637 XPCOMUtils.defineLazyPreferenceGetter(
    638  gConfig,
    639  "guardianEndpoint",
    640  "browser.ipProtection.guardian.endpoint",
    641  "https://vpn.mozilla.com"
    642 );
    643 XPCOMUtils.defineLazyPreferenceGetter(
    644  gConfig,
    645  "fxaOrigin",
    646  "identity.fxaccounts.remote.root"
    647 );