tor-browser

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

FxAccountsPairing.sys.mjs (15423B)


      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 {
      6  log,
      7  PREF_REMOTE_PAIRING_URI,
      8  COMMAND_PAIR_SUPP_METADATA,
      9  COMMAND_PAIR_AUTHORIZE,
     10  COMMAND_PAIR_DECLINE,
     11  COMMAND_PAIR_HEARTBEAT,
     12  COMMAND_PAIR_COMPLETE,
     13 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
     14 
     15 import {
     16  getFxAccountsSingleton,
     17  FxAccounts,
     18 } from "resource://gre/modules/FxAccounts.sys.mjs";
     19 
     20 const fxAccounts = getFxAccountsSingleton();
     21 import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
     22 
     23 ChromeUtils.importESModule("resource://services-common/utils.sys.mjs");
     24 const lazy = {};
     25 ChromeUtils.defineESModuleGetters(lazy, {
     26  FxAccountsPairingChannel:
     27    "resource://gre/modules/FxAccountsPairingChannel.sys.mjs",
     28 
     29  Weave: "resource://services-sync/main.sys.mjs",
     30  jwcrypto: "moz-src:///services/crypto/modules/jwcrypto.sys.mjs",
     31 });
     32 
     33 const PAIRING_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob:pair-auth-webchannel";
     34 // A pairing flow is not tied to a specific browser window, can also finish in
     35 // various ways and subsequently might leak a Web Socket, so just in case we
     36 // time out and free-up the resources after a specified amount of time.
     37 const FLOW_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes.
     38 
     39 class PairingStateMachine {
     40  constructor(emitter) {
     41    this._emitter = emitter;
     42    this._transition(SuppConnectionPending);
     43  }
     44 
     45  get currentState() {
     46    return this._currentState;
     47  }
     48 
     49  _transition(StateCtor, ...args) {
     50    const state = new StateCtor(this, ...args);
     51    this._currentState = state;
     52  }
     53 
     54  assertState(RequiredStates, messagePrefix = null) {
     55    if (!(RequiredStates instanceof Array)) {
     56      RequiredStates = [RequiredStates];
     57    }
     58    if (
     59      !RequiredStates.some(
     60        RequiredState => this._currentState instanceof RequiredState
     61      )
     62    ) {
     63      const msg = `${
     64        messagePrefix ? `${messagePrefix}. ` : ""
     65      }Valid expected states: ${RequiredStates.map(({ name }) => name).join(
     66        ", "
     67      )}. Current state: ${this._currentState.label}.`;
     68      throw new Error(msg);
     69    }
     70  }
     71 }
     72 
     73 /**
     74 * The pairing flow can be modeled by a finite state machine:
     75 * We start by connecting to a WebSocket channel (SuppConnectionPending).
     76 * Then the other party connects and requests some metadata from us (PendingConfirmations).
     77 * A confirmation happens locally first (PendingRemoteConfirmation)
     78 * or the oppposite (PendingLocalConfirmation).
     79 * Any side can decline this confirmation (Aborted).
     80 * Once both sides have confirmed, the pairing flow is finished (Completed).
     81 * During this flow errors can happen and should be handled (Errored).
     82 */
     83 class State {
     84  constructor(stateMachine, ...args) {
     85    this._transition = (...args) => stateMachine._transition(...args);
     86    this._notify = (...args) => stateMachine._emitter.emit(...args);
     87    this.init(...args);
     88  }
     89 
     90  init() {
     91    /* Does nothing by default but can be re-implemented. */
     92  }
     93 
     94  get label() {
     95    return this.constructor.name;
     96  }
     97 
     98  hasErrored(error) {
     99    this._notify("view:Error", error);
    100    this._transition(Errored, error);
    101  }
    102 
    103  hasAborted() {
    104    this._transition(Aborted);
    105  }
    106 }
    107 class SuppConnectionPending extends State {
    108  suppConnected(sender, oauthOptions) {
    109    this._transition(PendingConfirmations, sender, oauthOptions);
    110  }
    111 }
    112 class PendingConfirmationsState extends State {
    113  localConfirmed() {
    114    throw new Error("Subclasses must implement this method.");
    115  }
    116  remoteConfirmed() {
    117    throw new Error("Subclasses must implement this method.");
    118  }
    119 }
    120 class PendingConfirmations extends PendingConfirmationsState {
    121  init(sender, oauthOptions) {
    122    this.sender = sender;
    123    this.oauthOptions = oauthOptions;
    124  }
    125 
    126  localConfirmed() {
    127    this._transition(PendingRemoteConfirmation);
    128  }
    129 
    130  remoteConfirmed() {
    131    this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions);
    132  }
    133 }
    134 class PendingLocalConfirmation extends PendingConfirmationsState {
    135  init(sender, oauthOptions) {
    136    this.sender = sender;
    137    this.oauthOptions = oauthOptions;
    138  }
    139 
    140  localConfirmed() {
    141    this._transition(Completed);
    142  }
    143 
    144  remoteConfirmed() {
    145    throw new Error(
    146      "Insane state! Remote has already been confirmed at this point."
    147    );
    148  }
    149 }
    150 class PendingRemoteConfirmation extends PendingConfirmationsState {
    151  localConfirmed() {
    152    throw new Error(
    153      "Insane state! Local has already been confirmed at this point."
    154    );
    155  }
    156 
    157  remoteConfirmed() {
    158    this._transition(Completed);
    159  }
    160 }
    161 class Completed extends State {}
    162 class Aborted extends State {}
    163 class Errored extends State {
    164  init(error) {
    165    this.error = error;
    166  }
    167 }
    168 
    169 const flows = new Map();
    170 
    171 export class FxAccountsPairingFlow {
    172  static get(channelId) {
    173    return flows.get(channelId);
    174  }
    175 
    176  static finalizeAll() {
    177    for (const flow of flows) {
    178      flow.finalize();
    179    }
    180  }
    181 
    182  static async start(options) {
    183    const { emitter } = options;
    184    const fxaConfig = options.fxaConfig || FxAccounts.config;
    185    const fxa = options.fxAccounts || fxAccounts;
    186    const weave = options.weave || lazy.Weave;
    187    const flowTimeout = options.flowTimeout || FLOW_TIMEOUT_MS;
    188 
    189    const contentPairingURI = await fxaConfig.promisePairingURI();
    190    const wsUri = Services.urlFormatter.formatURLPref(PREF_REMOTE_PAIRING_URI);
    191    const pairingChannel =
    192      options.pairingChannel ||
    193      (await lazy.FxAccountsPairingChannel.create(wsUri));
    194    const { channelId, channelKey } = pairingChannel;
    195    const channelKeyB64 = ChromeUtils.base64URLEncode(channelKey, {
    196      pad: false,
    197    });
    198    const pairingFlow = new FxAccountsPairingFlow({
    199      channelId,
    200      pairingChannel,
    201      emitter,
    202      fxa,
    203      fxaConfig,
    204      flowTimeout,
    205      weave,
    206    });
    207    flows.set(channelId, pairingFlow);
    208 
    209    return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`;
    210  }
    211 
    212  constructor(options) {
    213    this._channelId = options.channelId;
    214    this._pairingChannel = options.pairingChannel;
    215    this._emitter = options.emitter;
    216    this._fxa = options.fxa;
    217    this._fxai = options.fxai || this._fxa._internal;
    218    this._fxaConfig = options.fxaConfig;
    219    this._weave = options.weave;
    220    this._stateMachine = new PairingStateMachine(this._emitter);
    221    this._setupListeners();
    222    this._flowTimeoutId = setTimeout(
    223      () => this._onFlowTimeout(),
    224      options.flowTimeout
    225    );
    226  }
    227 
    228  _onFlowTimeout() {
    229    log.warn(`The pairing flow ${this._channelId} timed out.`);
    230    this._onError(new Error("Timeout"));
    231    this.finalize();
    232  }
    233 
    234  _closeChannel() {
    235    if (!this._closed && !this._pairingChannel.closed) {
    236      this._pairingChannel.close();
    237      this._closed = true;
    238    }
    239  }
    240 
    241  finalize() {
    242    this._closeChannel();
    243    clearTimeout(this._flowTimeoutId);
    244    // Free up resources and let the GC do its thing.
    245    flows.delete(this._channelId);
    246  }
    247 
    248  _setupListeners() {
    249    this._pairingChannel.addEventListener(
    250      "message",
    251      ({ detail: { sender, data } }) =>
    252        this.onPairingChannelMessage(sender, data)
    253    );
    254    this._pairingChannel.addEventListener("error", event =>
    255      this._onPairingChannelError(event.detail.error)
    256    );
    257    this._emitter.on("view:Closed", () => this.onPrefViewClosed());
    258  }
    259 
    260  _onAbort() {
    261    this._stateMachine.currentState.hasAborted();
    262    this.finalize();
    263  }
    264 
    265  _onError(error) {
    266    this._stateMachine.currentState.hasErrored(error);
    267    this._closeChannel();
    268  }
    269 
    270  _onPairingChannelError(error) {
    271    log.error("Pairing channel error", error);
    272    this._onError(error);
    273  }
    274 
    275  // Any non-falsy returned value is sent back through WebChannel.
    276  async onWebChannelMessage(command) {
    277    const stateMachine = this._stateMachine;
    278    const curState = stateMachine.currentState;
    279    try {
    280      switch (command) {
    281        case COMMAND_PAIR_SUPP_METADATA: {
    282          stateMachine.assertState(
    283            [PendingConfirmations, PendingLocalConfirmation],
    284            `Wrong state for ${command}`
    285          );
    286          const {
    287            ua,
    288            city,
    289            region,
    290            country,
    291            remote: ipAddress,
    292          } = curState.sender;
    293          return { ua, city, region, country, ipAddress };
    294        }
    295        case COMMAND_PAIR_AUTHORIZE: {
    296          stateMachine.assertState(
    297            [PendingConfirmations, PendingLocalConfirmation],
    298            `Wrong state for ${command}`
    299          );
    300          const {
    301            client_id,
    302            state,
    303            scope,
    304            code_challenge,
    305            code_challenge_method,
    306            keys_jwk,
    307          } = curState.oauthOptions;
    308          const authorizeParams = {
    309            client_id,
    310            access_type: "offline",
    311            state,
    312            scope,
    313            code_challenge,
    314            code_challenge_method,
    315            keys_jwk,
    316          };
    317          const codeAndState = await this._authorizeOAuthCode(authorizeParams);
    318          if (codeAndState.state != state) {
    319            throw new Error(`OAuth state mismatch`);
    320          }
    321          await this._pairingChannel.send({
    322            message: "pair:auth:authorize",
    323            data: {
    324              ...codeAndState,
    325            },
    326          });
    327          curState.localConfirmed();
    328          break;
    329        }
    330        case COMMAND_PAIR_DECLINE:
    331          this._onAbort();
    332          break;
    333        case COMMAND_PAIR_HEARTBEAT: {
    334          if (curState instanceof Errored || this._pairingChannel.closed) {
    335            return { err: curState.error.message || "Pairing channel closed" };
    336          }
    337          const suppAuthorized = !(
    338            curState instanceof PendingConfirmations ||
    339            curState instanceof PendingRemoteConfirmation
    340          );
    341          return { suppAuthorized };
    342        }
    343        case COMMAND_PAIR_COMPLETE:
    344          this.finalize();
    345          break;
    346        default:
    347          throw new Error(`Received unknown WebChannel command: ${command}`);
    348      }
    349    } catch (e) {
    350      log.error(e);
    351      curState.hasErrored(e);
    352    }
    353    return {};
    354  }
    355 
    356  async onPairingChannelMessage(sender, payload) {
    357    const { message } = payload;
    358    const stateMachine = this._stateMachine;
    359    const curState = stateMachine.currentState;
    360    try {
    361      switch (message) {
    362        case "pair:supp:request": {
    363          stateMachine.assertState(
    364            SuppConnectionPending,
    365            `Wrong state for ${message}`
    366          );
    367          const oauthUri = await this._fxaConfig.promiseOAuthURI();
    368          const { uid, email, avatar, displayName } =
    369            await this._fxa.getSignedInUser();
    370          const deviceName = this._weave.Service.clientsEngine.localName;
    371          await this._pairingChannel.send({
    372            message: "pair:auth:metadata",
    373            data: {
    374              email,
    375              avatar,
    376              displayName,
    377              deviceName,
    378            },
    379          });
    380          const {
    381            client_id,
    382            state,
    383            scope,
    384            code_challenge,
    385            code_challenge_method,
    386            keys_jwk,
    387          } = payload.data;
    388          const url = new URL(oauthUri);
    389          url.searchParams.append("client_id", client_id);
    390          url.searchParams.append("scope", scope);
    391          url.searchParams.append("email", email);
    392          url.searchParams.append("uid", uid);
    393          url.searchParams.append("channel_id", this._channelId);
    394          url.searchParams.append("redirect_uri", PAIRING_REDIRECT_URI);
    395          this._emitter.emit("view:SwitchToWebContent", url.href);
    396          curState.suppConnected(sender, {
    397            client_id,
    398            state,
    399            scope,
    400            code_challenge,
    401            code_challenge_method,
    402            keys_jwk,
    403          });
    404          break;
    405        }
    406        case "pair:supp:authorize":
    407          stateMachine.assertState(
    408            [PendingConfirmations, PendingRemoteConfirmation],
    409            `Wrong state for ${message}`
    410          );
    411          curState.remoteConfirmed();
    412          break;
    413        default:
    414          throw new Error(
    415            `Received unknown Pairing Channel message: ${message}`
    416          );
    417      }
    418    } catch (e) {
    419      log.error(e);
    420      curState.hasErrored(e);
    421    }
    422  }
    423 
    424  onPrefViewClosed() {
    425    const curState = this._stateMachine.currentState;
    426    // We don't want to stop the pairing process in the later stages.
    427    if (
    428      curState instanceof SuppConnectionPending ||
    429      curState instanceof Aborted ||
    430      curState instanceof Errored
    431    ) {
    432      this.finalize();
    433    }
    434  }
    435 
    436  /**
    437   * Grant an OAuth authorization code for the connecting client.
    438   *
    439   * @param {object} options
    440   * @param options.client_id
    441   * @param options.state
    442   * @param options.scope
    443   * @param options.access_type
    444   * @param options.code_challenge_method
    445   * @param options.code_challenge
    446   * @param [options.keys_jwe]
    447   * @returns {Promise<object>} Object containing "code" and "state" properties.
    448   */
    449  _authorizeOAuthCode(options) {
    450    return this._fxa._withVerifiedAccountState(async state => {
    451      const { sessionToken } = await state.getUserAccountData(["sessionToken"]);
    452      const params = { ...options };
    453      if (params.keys_jwk) {
    454        const jwk = JSON.parse(
    455          new TextDecoder().decode(
    456            ChromeUtils.base64URLDecode(params.keys_jwk, { padding: "reject" })
    457          )
    458        );
    459        params.keys_jwe = await this._createKeysJWE(
    460          sessionToken,
    461          params.client_id,
    462          params.scope,
    463          jwk
    464        );
    465        delete params.keys_jwk;
    466      }
    467      try {
    468        return await this._fxai.fxAccountsClient.oauthAuthorize(
    469          sessionToken,
    470          params
    471        );
    472      } catch (err) {
    473        throw this._fxai._errorToErrorClass(err);
    474      }
    475    });
    476  }
    477 
    478  /**
    479   * Create a JWE to deliver keys to another client via the OAuth scoped-keys flow.
    480   *
    481   * This method is used to transfer key material to another client, by providing
    482   * an appropriately-encrypted value for the `keys_jwe` OAuth response parameter.
    483   * Since we're transferring keys from one client to another, two things must be
    484   * true:
    485   *
    486   *   * This client must actually have the key.
    487   *   * The other client must be allowed to request that key.
    488   *
    489   * @param {string} sessionToken the sessionToken to use when fetching key metadata
    490   * @param {string} clientId the client requesting access to our keys
    491   * @param {string} scopes Space separated requested scopes being requested
    492   * @param {object} jwk Ephemeral JWK provided by the client for secure key transfer
    493   */
    494  async _createKeysJWE(sessionToken, clientId, scopes, jwk) {
    495    // This checks with the FxA server about what scopes the client is allowed.
    496    // Note that we pass the requesting client_id here, not our own client_id.
    497    const clientKeyData = await this._fxai.fxAccountsClient.getScopedKeyData(
    498      sessionToken,
    499      clientId,
    500      scopes
    501    );
    502    const scopedKeys = {};
    503    for (const scope of Object.keys(clientKeyData)) {
    504      const key = await this._fxai.keys.getKeyForScope(scope);
    505      if (!key) {
    506        throw new Error(`Key not available for scope "${scope}"`);
    507      }
    508      scopedKeys[scope] = key;
    509    }
    510    return lazy.jwcrypto.generateJWE(
    511      jwk,
    512      new TextEncoder().encode(JSON.stringify(scopedKeys))
    513    );
    514  }
    515 }