tor-browser

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

FxAccountsDevice.sys.mjs (22575B)


      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 import {
      8  log,
      9  ERRNO_DEVICE_SESSION_CONFLICT,
     10  ERRNO_UNKNOWN_DEVICE,
     11  ON_NEW_DEVICE_ID,
     12  ON_DEVICELIST_UPDATED,
     13  ON_DEVICE_CONNECTED_NOTIFICATION,
     14  ON_DEVICE_DISCONNECTED_NOTIFICATION,
     15  ONVERIFIED_NOTIFICATION,
     16  PREF_ACCOUNT_ROOT,
     17 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
     18 
     19 import { DEVICE_TYPE_DESKTOP } from "resource://services-sync/constants.sys.mjs";
     20 
     21 const lazy = {};
     22 
     23 ChromeUtils.defineESModuleGetters(lazy, {
     24  CommonUtils: "resource://services-common/utils.sys.mjs",
     25 });
     26 
     27 const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name";
     28 XPCOMUtils.defineLazyPreferenceGetter(
     29  lazy,
     30  "pref_localDeviceName",
     31  PREF_LOCAL_DEVICE_NAME,
     32  ""
     33 );
     34 
     35 const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name";
     36 
     37 // Sanitizes all characters which the FxA server considers invalid, replacing
     38 // them with the unicode replacement character.
     39 // At time of writing, FxA has a regex DISPLAY_SAFE_UNICODE_WITH_NON_BMP, which
     40 // the regex below is based on.
     41 const INVALID_NAME_CHARS =
     42  // eslint-disable-next-line no-control-regex
     43  /[\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uE000-\uF8FF\uFFF9-\uFFFC\uFFFE-\uFFFF]/g;
     44 const MAX_NAME_LEN = 255;
     45 const REPLACEMENT_CHAR = "\uFFFD";
     46 
     47 function sanitizeDeviceName(name) {
     48  return name
     49    .substr(0, MAX_NAME_LEN)
     50    .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR);
     51 }
     52 
     53 // Everything to do with FxA devices.
     54 export class FxAccountsDevice {
     55  constructor(fxai) {
     56    this._fxai = fxai;
     57    this._deviceListCache = null;
     58    this._fetchAndCacheDeviceListPromise = null;
     59 
     60    // The current version of the device registration, we use this to re-register
     61    // devices after we update what we send on device registration.
     62    this.DEVICE_REGISTRATION_VERSION = 2;
     63 
     64    // This is to avoid multiple sequential syncs ending up calling
     65    // this expensive endpoint multiple times in a row.
     66    this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 1 * 60 * 1000; // 1 minute
     67 
     68    // Invalidate our cached device list when a device is connected or disconnected.
     69    Services.obs.addObserver(this, ON_DEVICE_CONNECTED_NOTIFICATION, true);
     70    Services.obs.addObserver(this, ON_DEVICE_DISCONNECTED_NOTIFICATION, true);
     71    // A user becoming verified probably means we need to re-register the device
     72    // because we are now able to get the sendtab keys.
     73    Services.obs.addObserver(this, ONVERIFIED_NOTIFICATION, true);
     74  }
     75 
     76  async getLocalId() {
     77    return this._withCurrentAccountState(currentState => {
     78      // It turns out _updateDeviceRegistrationIfNecessary() does exactly what we
     79      // need.
     80      return this._updateDeviceRegistrationIfNecessary(currentState);
     81    });
     82  }
     83 
     84  // Generate a client name if we don't have a useful one yet
     85  getDefaultLocalName() {
     86    let user = Services.env.get("USER") || Services.env.get("USERNAME");
     87    // Note that we used to fall back to the "services.sync.username" pref here,
     88    // but that's no longer suitable in a world where sync might not be
     89    // configured. However, we almost never *actually* fell back to that, and
     90    // doing so sanely here would mean making this function async, which we don't
     91    // really want to do yet.
     92 
     93    // A little hack for people using the the moz-build environment on Windows
     94    // which sets USER to the literal "%USERNAME%" (yes, really)
     95    if (user == "%USERNAME%" && Services.env.get("USERNAME")) {
     96      user = Services.env.get("USERNAME");
     97    }
     98 
     99    // The DNS service may fail to provide a hostname in edge-cases we don't
    100    // fully understand - bug 1391488.
    101    let hostname;
    102    try {
    103      // hostname of the system, usually assigned by the user or admin
    104      hostname = Services.dns.myHostName;
    105    } catch (ex) {
    106      console.error(ex);
    107    }
    108    let system =
    109      // 'device' is defined on unix systems
    110      Services.sysinfo.get("device") ||
    111      hostname ||
    112      // fall back on ua info string
    113      Cc["@mozilla.org/network/protocol;1?name=http"].getService(
    114        Ci.nsIHttpProtocolHandler
    115      ).oscpu;
    116 
    117    const l10n = new Localization(
    118      ["services/accounts.ftl", "branding/brand.ftl"],
    119      true
    120    );
    121    return sanitizeDeviceName(
    122      l10n.formatValueSync("account-client-name", { user, system })
    123    );
    124  }
    125 
    126  getLocalName() {
    127    // We used to store this in services.sync.client.name, but now store it
    128    // under an fxa-specific location.
    129    let deprecated_value = Services.prefs.getStringPref(
    130      PREF_DEPRECATED_DEVICE_NAME,
    131      ""
    132    );
    133    if (deprecated_value) {
    134      Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, deprecated_value);
    135      Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME);
    136    }
    137    let name = lazy.pref_localDeviceName;
    138    if (!name) {
    139      name = this.getDefaultLocalName();
    140      Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, name);
    141    }
    142    // We need to sanitize here because some names were generated before we
    143    // started sanitizing.
    144    return sanitizeDeviceName(name);
    145  }
    146 
    147  setLocalName(newName) {
    148    Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME);
    149    Services.prefs.setStringPref(
    150      PREF_LOCAL_DEVICE_NAME,
    151      sanitizeDeviceName(newName)
    152    );
    153    // Update the registration in the background.
    154    this.updateDeviceRegistration().catch(error => {
    155      log.warn("failed to update fxa device registration", error);
    156    });
    157  }
    158 
    159  getLocalType() {
    160    return DEVICE_TYPE_DESKTOP;
    161  }
    162 
    163  /**
    164   * Returns the most recently fetched device list, or `null` if the list
    165   * hasn't been fetched yet. This is synchronous, so that consumers like
    166   * Send Tab can render the device list right away, without waiting for
    167   * it to refresh.
    168   *
    169   * @type {?Array}
    170   */
    171  get recentDeviceList() {
    172    return this._deviceListCache ? this._deviceListCache.devices : null;
    173  }
    174 
    175  /**
    176   * Refreshes the device list. After this function returns, consumers can
    177   * access the new list using the `recentDeviceList` getter. Note that
    178   * multiple concurrent calls to `refreshDeviceList` will only refresh the
    179   * list once.
    180   *
    181   * @param  {boolean} [options.ignoreCached]
    182   *         If `true`, forces a refresh, even if the cached device list is
    183   *         still fresh. Defaults to `false`.
    184   * @return {Promise<boolean>}
    185   *         `true` if the list was refreshed, `false` if the cached list is
    186   *         fresh. Rejects if an error occurs refreshing the list or device
    187   *         push registration.
    188   */
    189  async refreshDeviceList({ ignoreCached = false } = {}) {
    190    // If we're already refreshing the list in the background, let that finish.
    191    if (this._fetchAndCacheDeviceListPromise) {
    192      log.info("Already fetching device list, return existing promise");
    193      return this._fetchAndCacheDeviceListPromise;
    194    }
    195 
    196    // If the cache is fresh enough, don't refresh it again.
    197    if (!ignoreCached && this._deviceListCache) {
    198      const ageOfCache = this._fxai.now() - this._deviceListCache.lastFetch;
    199      if (ageOfCache < this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS) {
    200        log.info("Device list cache is fresh, re-using it");
    201        return false;
    202      }
    203    }
    204 
    205    log.info("fetching updated device list");
    206    this._fetchAndCacheDeviceListPromise = (async () => {
    207      try {
    208        const devices = await this._withVerifiedAccountState(
    209          async currentState => {
    210            const accountData = await currentState.getUserAccountData([
    211              "sessionToken",
    212              "device",
    213            ]);
    214            const devices = await this._fxai.fxAccountsClient.getDeviceList(
    215              accountData.sessionToken
    216            );
    217            log.info(
    218              `Got new device list: ${devices.map(d => d.id).join(", ")}`
    219            );
    220 
    221            await this._refreshRemoteDevice(currentState, accountData, devices);
    222            return devices;
    223          }
    224        );
    225        log.info("updating the cache");
    226        // Be careful to only update the cache once the above has resolved, so
    227        // we know that the current account state didn't change underneath us.
    228        this._deviceListCache = {
    229          lastFetch: this._fxai.now(),
    230          devices,
    231        };
    232        Services.obs.notifyObservers(null, ON_DEVICELIST_UPDATED);
    233        return true;
    234      } finally {
    235        this._fetchAndCacheDeviceListPromise = null;
    236      }
    237    })();
    238    return this._fetchAndCacheDeviceListPromise;
    239  }
    240 
    241  async _refreshRemoteDevice(currentState, accountData, remoteDevices) {
    242    // Check if our push registration previously succeeded and is still
    243    // good (although background device registration means it's possible
    244    // we'll be fetching the device list before we've actually
    245    // registered ourself!)
    246    // (For a missing subscription we check for an explicit 'null' -
    247    // both to help tests and as a safety valve - missing might mean
    248    // "no push available" for self-hosters or similar?)
    249    const ourDevice = remoteDevices.find(device => device.isCurrentDevice);
    250    const subscription = await this._fxai.fxaPushService.getSubscription();
    251    if (
    252      ourDevice &&
    253      (ourDevice.pushCallback === null || // fxa server doesn't know our subscription.
    254        ourDevice.pushEndpointExpired || // fxa server thinks it has expired.
    255        !subscription || // we don't have a local subscription.
    256        subscription.isExpired() || // our local subscription is expired.
    257        ourDevice.pushCallback != subscription.endpoint) // we don't agree with fxa.
    258    ) {
    259      log.warn(`Our push endpoint needs resubscription`);
    260      await this._fxai.fxaPushService.unsubscribe();
    261      await this._registerOrUpdateDevice(currentState, accountData);
    262      // and there's a reasonable chance there are commands waiting.
    263      await this._fxai.commands.pollDeviceCommands();
    264    } else if (
    265      ourDevice &&
    266      (await this._checkRemoteCommandsUpdateNeeded(ourDevice.availableCommands))
    267    ) {
    268      log.warn(`Our commands need to be updated on the server`);
    269      await this._registerOrUpdateDevice(currentState, accountData);
    270    } else {
    271      log.trace(`Our push subscription looks OK`);
    272    }
    273  }
    274 
    275  async updateDeviceRegistration() {
    276    return this._withCurrentAccountState(async currentState => {
    277      const signedInUser = await currentState.getUserAccountData([
    278        "sessionToken",
    279        "device",
    280      ]);
    281      if (signedInUser) {
    282        await this._registerOrUpdateDevice(currentState, signedInUser);
    283      }
    284    });
    285  }
    286 
    287  async updateDeviceRegistrationIfNecessary() {
    288    return this._withCurrentAccountState(currentState => {
    289      return this._updateDeviceRegistrationIfNecessary(currentState);
    290    });
    291  }
    292 
    293  reset() {
    294    this._deviceListCache = null;
    295    this._fetchAndCacheDeviceListPromise = null;
    296  }
    297 
    298  /**
    299   * Here begin our internal helper methods.
    300   *
    301   * Many of these methods take the current account state as first argument,
    302   * in order to avoid racing our state updates with e.g. the uer signing
    303   * out while we're in the middle of an update. If this does happen, the
    304   * resulting promise will be rejected rather than persisting stale state.
    305   *
    306   */
    307 
    308  _withCurrentAccountState(func) {
    309    return this._fxai.withCurrentAccountState(async currentState => {
    310      try {
    311        return await func(currentState);
    312      } catch (err) {
    313        // `_handleTokenError` always throws, this syntax keeps the linter happy.
    314        // TODO: probably `_handleTokenError` could be done by `_fxai.withCurrentAccountState`
    315        // internally rather than us having to remember to do it here.
    316        throw await this._fxai._handleTokenError(err);
    317      }
    318    });
    319  }
    320 
    321  _withVerifiedAccountState(func) {
    322    return this._fxai.withVerifiedAccountState(async currentState => {
    323      try {
    324        return await func(currentState);
    325      } catch (err) {
    326        // `_handleTokenError` always throws, this syntax keeps the linter happy.
    327        throw await this._fxai._handleTokenError(err);
    328      }
    329    });
    330  }
    331 
    332  async _checkDeviceUpdateNeeded(device) {
    333    // There is no device registered or the device registration is outdated.
    334    // Either way, we should register the device with FxA
    335    // before returning the id to the caller.
    336    const availableCommandsKeys = Object.keys(
    337      await this._fxai.commands.availableCommands()
    338    ).sort();
    339    return (
    340      !device ||
    341      !device.registrationVersion ||
    342      device.registrationVersion < this.DEVICE_REGISTRATION_VERSION ||
    343      !device.registeredCommandsKeys ||
    344      !lazy.CommonUtils.arrayEqual(
    345        device.registeredCommandsKeys,
    346        availableCommandsKeys
    347      )
    348    );
    349  }
    350 
    351  async _checkRemoteCommandsUpdateNeeded(remoteAvailableCommands) {
    352    if (!remoteAvailableCommands) {
    353      return true;
    354    }
    355    const remoteAvailableCommandsKeys = Object.keys(
    356      remoteAvailableCommands
    357    ).sort();
    358    const localAvailableCommands =
    359      await this._fxai.commands.availableCommands();
    360    const localAvailableCommandsKeys = Object.keys(
    361      localAvailableCommands
    362    ).sort();
    363 
    364    if (
    365      !lazy.CommonUtils.arrayEqual(
    366        localAvailableCommandsKeys,
    367        remoteAvailableCommandsKeys
    368      )
    369    ) {
    370      return true;
    371    }
    372 
    373    for (const key of localAvailableCommandsKeys) {
    374      if (remoteAvailableCommands[key] !== localAvailableCommands[key]) {
    375        return true;
    376      }
    377    }
    378    return false;
    379  }
    380 
    381  async _updateDeviceRegistrationIfNecessary(currentState) {
    382    let data = await currentState.getUserAccountData([
    383      "sessionToken",
    384      "device",
    385    ]);
    386    if (!data) {
    387      // Can't register a device without a signed-in user.
    388      return null;
    389    }
    390    const { device } = data;
    391    if (await this._checkDeviceUpdateNeeded(device)) {
    392      return this._registerOrUpdateDevice(currentState, data);
    393    }
    394    // Return the device ID we already had.
    395    return device.id;
    396  }
    397 
    398  // If you change what we send to the FxA servers during device registration,
    399  // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
    400  // devices to re-register when Firefox updates.
    401  async _registerOrUpdateDevice(currentState, signedInUser) {
    402    // This method has the side-effect of setting some account-related prefs
    403    // (e.g. for caching the device name) so it's important we don't execute it
    404    // if the signed-in state has changed.
    405    if (!currentState.isCurrent) {
    406      throw new Error(
    407        "_registerOrUpdateDevice called after a different user has signed in"
    408      );
    409    }
    410 
    411    const { sessionToken, device: currentDevice } = signedInUser;
    412    if (!sessionToken) {
    413      throw new Error("_registerOrUpdateDevice called without a session token");
    414    }
    415 
    416    try {
    417      const subscription =
    418        await this._fxai.fxaPushService.registerPushEndpoint();
    419      const deviceName = this.getLocalName();
    420      let deviceOptions = {};
    421 
    422      // if we were able to obtain a subscription
    423      if (subscription && subscription.endpoint) {
    424        deviceOptions.pushCallback = subscription.endpoint;
    425        let publicKey = subscription.getKey("p256dh");
    426        let authKey = subscription.getKey("auth");
    427        if (publicKey && authKey) {
    428          deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey);
    429          deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey);
    430        }
    431      }
    432      deviceOptions.availableCommands =
    433        await this._fxai.commands.availableCommands();
    434      const availableCommandsKeys = Object.keys(
    435        deviceOptions.availableCommands
    436      ).sort();
    437      log.info("registering with available commands", availableCommandsKeys);
    438 
    439      let device;
    440      let is_existing = currentDevice && currentDevice.id;
    441      if (is_existing) {
    442        log.debug("updating existing device details");
    443        device = await this._fxai.fxAccountsClient.updateDevice(
    444          sessionToken,
    445          currentDevice.id,
    446          deviceName,
    447          deviceOptions
    448        );
    449      } else {
    450        log.debug("registering new device details");
    451        device = await this._fxai.fxAccountsClient.registerDevice(
    452          sessionToken,
    453          deviceName,
    454          this.getLocalType(),
    455          deviceOptions
    456        );
    457      }
    458 
    459      // Get the freshest device props before updating them.
    460      let { device: deviceProps } = await currentState.getUserAccountData([
    461        "device",
    462      ]);
    463      await currentState.updateUserAccountData({
    464        device: {
    465          ...deviceProps, // Copy the other properties (e.g. handledCommands).
    466          id: device.id,
    467          registrationVersion: this.DEVICE_REGISTRATION_VERSION,
    468          registeredCommandsKeys: availableCommandsKeys,
    469        },
    470      });
    471      // Must send the notification after we've written the storage.
    472      if (!is_existing) {
    473        Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID);
    474      }
    475      return device.id;
    476    } catch (error) {
    477      return this._handleDeviceError(currentState, error, sessionToken);
    478    }
    479  }
    480 
    481  async _handleDeviceError(currentState, error, sessionToken) {
    482    try {
    483      if (error.code === 400) {
    484        if (error.errno === ERRNO_UNKNOWN_DEVICE) {
    485          return this._recoverFromUnknownDevice(currentState);
    486        }
    487 
    488        if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
    489          return this._recoverFromDeviceSessionConflict(
    490            currentState,
    491            error,
    492            sessionToken
    493          );
    494        }
    495      }
    496 
    497      // `_handleTokenError` always throws, this syntax keeps the linter happy.
    498      // Note that the re-thrown error is immediately caught, logged and ignored
    499      // by the containing scope here, which is why we have to `_handleTokenError`
    500      // ourselves rather than letting it bubble up for handling by the caller.
    501      throw await this._fxai._handleTokenError(error);
    502    } catch (error) {
    503      await this._logErrorAndResetDeviceRegistrationVersion(
    504        currentState,
    505        error
    506      );
    507      return null;
    508    }
    509  }
    510 
    511  async _recoverFromUnknownDevice(currentState) {
    512    // FxA did not recognise the device id. Handle it by clearing the device
    513    // id on the account data. At next sync or next sign-in, registration is
    514    // retried and should succeed.
    515    log.warn("unknown device id, clearing the local device data");
    516    try {
    517      await currentState.updateUserAccountData({
    518        device: null,
    519        encryptedSendTabKeys: null,
    520      });
    521    } catch (error) {
    522      await this._logErrorAndResetDeviceRegistrationVersion(
    523        currentState,
    524        error
    525      );
    526    }
    527    return null;
    528  }
    529 
    530  async _recoverFromDeviceSessionConflict(currentState, error, sessionToken) {
    531    // FxA has already associated this session with a different device id.
    532    // Perhaps we were beaten in a race to register. Handle the conflict:
    533    //   1. Fetch the list of devices for the current user from FxA.
    534    //   2. Look for ourselves in the list.
    535    //   3. If we find a match, set the correct device id and device registration
    536    //      version on the account data and return the correct device id. At next
    537    //      sync or next sign-in, registration is retried and should succeed.
    538    //   4. If we don't find a match, log the original error.
    539    log.warn(
    540      "device session conflict, attempting to ascertain the correct device id"
    541    );
    542    try {
    543      const devices =
    544        await this._fxai.fxAccountsClient.getDeviceList(sessionToken);
    545      const matchingDevices = devices.filter(device => device.isCurrentDevice);
    546      const length = matchingDevices.length;
    547      if (length === 1) {
    548        const deviceId = matchingDevices[0].id;
    549        await currentState.updateUserAccountData({
    550          device: {
    551            id: deviceId,
    552            registrationVersion: null,
    553          },
    554          encryptedSendTabKeys: null,
    555        });
    556        return deviceId;
    557      }
    558      if (length > 1) {
    559        log.error(
    560          "insane server state, " + length + " devices for this session"
    561        );
    562      }
    563      await this._logErrorAndResetDeviceRegistrationVersion(
    564        currentState,
    565        error
    566      );
    567    } catch (secondError) {
    568      log.error("failed to recover from device-session conflict", secondError);
    569      await this._logErrorAndResetDeviceRegistrationVersion(
    570        currentState,
    571        error
    572      );
    573    }
    574    return null;
    575  }
    576 
    577  async _logErrorAndResetDeviceRegistrationVersion(currentState, error) {
    578    // Device registration should never cause other operations to fail.
    579    // If we've reached this point, just log the error and reset the device
    580    // on the account data. At next sync or next sign-in,
    581    // registration will be retried.
    582    log.error("device registration failed", error);
    583    try {
    584      await currentState.updateUserAccountData({
    585        device: null,
    586        encryptedSendTabKeys: null,
    587      });
    588    } catch (secondError) {
    589      log.error(
    590        "failed to reset the device registration version, device registration won't be retried",
    591        secondError
    592      );
    593    }
    594  }
    595 
    596  // Kick off a background refresh when a device is connected or disconnected.
    597  observe(subject, topic, data) {
    598    switch (topic) {
    599      case ON_DEVICE_CONNECTED_NOTIFICATION:
    600        this.refreshDeviceList({ ignoreCached: true }).catch(error => {
    601          log.warn(
    602            "failed to refresh devices after connecting a new device",
    603            error
    604          );
    605        });
    606        break;
    607      case ON_DEVICE_DISCONNECTED_NOTIFICATION: {
    608        let json = JSON.parse(data);
    609        if (!json.isLocalDevice) {
    610          // If we're the device being disconnected, don't bother fetching a new
    611          // list, since our session token is now invalid.
    612          this.refreshDeviceList({ ignoreCached: true }).catch(error => {
    613            log.warn(
    614              "failed to refresh devices after disconnecting a device",
    615              error
    616            );
    617          });
    618        }
    619        break;
    620      }
    621      case ONVERIFIED_NOTIFICATION:
    622        this.updateDeviceRegistrationIfNecessary().catch(error => {
    623          log.warn(
    624            "updateDeviceRegistrationIfNecessary failed after verification",
    625            error
    626          );
    627        });
    628        break;
    629    }
    630  }
    631 }
    632 
    633 FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([
    634  "nsIObserver",
    635  "nsISupportsWeakReference",
    636 ]);
    637 
    638 function urlsafeBase64Encode(buffer) {
    639  return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
    640 }