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