/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const { log, ERRNO_DEVICE_SESSION_CONFLICT, ERRNO_UNKNOWN_DEVICE, ON_NEW_DEVICE_ID, ON_DEVICELIST_UPDATED, ON_DEVICE_CONNECTED_NOTIFICATION, ON_DEVICE_DISCONNECTED_NOTIFICATION, ONVERIFIED_NOTIFICATION, PREF_ACCOUNT_ROOT, } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); import { DEVICE_TYPE_DESKTOP } from "resource://services-sync/constants.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CommonUtils: "resource://services-common/utils.sys.mjs", }); const PREF_LOCAL_DEVICE_NAME = PREF_ACCOUNT_ROOT + "device.name"; XPCOMUtils.defineLazyPreferenceGetter( lazy, "pref_localDeviceName", PREF_LOCAL_DEVICE_NAME, "" ); const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name"; // Sanitizes all characters which the FxA server considers invalid, replacing // them with the unicode replacement character. // At time of writing, FxA has a regex DISPLAY_SAFE_UNICODE_WITH_NON_BMP, which // the regex below is based on. const INVALID_NAME_CHARS = // eslint-disable-next-line no-control-regex /[\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uE000-\uF8FF\uFFF9-\uFFFC\uFFFE-\uFFFF]/g; const MAX_NAME_LEN = 255; const REPLACEMENT_CHAR = "\uFFFD"; function sanitizeDeviceName(name) { return name .substr(0, MAX_NAME_LEN) .replace(INVALID_NAME_CHARS, REPLACEMENT_CHAR); } // Everything to do with FxA devices. export class FxAccountsDevice { constructor(fxai) { this._fxai = fxai; this._deviceListCache = null; this._fetchAndCacheDeviceListPromise = null; // The current version of the device registration, we use this to re-register // devices after we update what we send on device registration. this.DEVICE_REGISTRATION_VERSION = 2; // This is to avoid multiple sequential syncs ending up calling // this expensive endpoint multiple times in a row. this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 1 * 60 * 1000; // 1 minute // Invalidate our cached device list when a device is connected or disconnected. Services.obs.addObserver(this, ON_DEVICE_CONNECTED_NOTIFICATION, true); Services.obs.addObserver(this, ON_DEVICE_DISCONNECTED_NOTIFICATION, true); // A user becoming verified probably means we need to re-register the device // because we are now able to get the sendtab keys. Services.obs.addObserver(this, ONVERIFIED_NOTIFICATION, true); } async getLocalId() { return this._withCurrentAccountState(currentState => { // It turns out _updateDeviceRegistrationIfNecessary() does exactly what we // need. return this._updateDeviceRegistrationIfNecessary(currentState); }); } // Generate a client name if we don't have a useful one yet getDefaultLocalName() { let user = Services.env.get("USER") || Services.env.get("USERNAME"); // Note that we used to fall back to the "services.sync.username" pref here, // but that's no longer suitable in a world where sync might not be // configured. However, we almost never *actually* fell back to that, and // doing so sanely here would mean making this function async, which we don't // really want to do yet. // A little hack for people using the the moz-build environment on Windows // which sets USER to the literal "%USERNAME%" (yes, really) if (user == "%USERNAME%" && Services.env.get("USERNAME")) { user = Services.env.get("USERNAME"); } // The DNS service may fail to provide a hostname in edge-cases we don't // fully understand - bug 1391488. let hostname; try { // hostname of the system, usually assigned by the user or admin hostname = Services.dns.myHostName; } catch (ex) { console.error(ex); } let system = // 'device' is defined on unix systems Services.sysinfo.get("device") || hostname || // fall back on ua info string Cc["@mozilla.org/network/protocol;1?name=http"].getService( Ci.nsIHttpProtocolHandler ).oscpu; const l10n = new Localization( ["services/accounts.ftl", "branding/brand.ftl"], true ); return sanitizeDeviceName( l10n.formatValueSync("account-client-name", { user, system }) ); } getLocalName() { // We used to store this in services.sync.client.name, but now store it // under an fxa-specific location. let deprecated_value = Services.prefs.getStringPref( PREF_DEPRECATED_DEVICE_NAME, "" ); if (deprecated_value) { Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, deprecated_value); Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME); } let name = lazy.pref_localDeviceName; if (!name) { name = this.getDefaultLocalName(); Services.prefs.setStringPref(PREF_LOCAL_DEVICE_NAME, name); } // We need to sanitize here because some names were generated before we // started sanitizing. return sanitizeDeviceName(name); } setLocalName(newName) { Services.prefs.clearUserPref(PREF_DEPRECATED_DEVICE_NAME); Services.prefs.setStringPref( PREF_LOCAL_DEVICE_NAME, sanitizeDeviceName(newName) ); // Update the registration in the background. this.updateDeviceRegistration().catch(error => { log.warn("failed to update fxa device registration", error); }); } getLocalType() { return DEVICE_TYPE_DESKTOP; } /** * Returns the most recently fetched device list, or `null` if the list * hasn't been fetched yet. This is synchronous, so that consumers like * Send Tab can render the device list right away, without waiting for * it to refresh. * * @type {?Array} */ get recentDeviceList() { return this._deviceListCache ? this._deviceListCache.devices : null; } /** * Refreshes the device list. After this function returns, consumers can * access the new list using the `recentDeviceList` getter. Note that * multiple concurrent calls to `refreshDeviceList` will only refresh the * list once. * * @param {Boolean} [options.ignoreCached] * If `true`, forces a refresh, even if the cached device list is * still fresh. Defaults to `false`. * @return {Promise} * `true` if the list was refreshed, `false` if the cached list is * fresh. Rejects if an error occurs refreshing the list or device * push registration. */ async refreshDeviceList({ ignoreCached = false } = {}) { // If we're already refreshing the list in the background, let that finish. if (this._fetchAndCacheDeviceListPromise) { log.info("Already fetching device list, return existing promise"); return this._fetchAndCacheDeviceListPromise; } // If the cache is fresh enough, don't refresh it again. if (!ignoreCached && this._deviceListCache) { const ageOfCache = this._fxai.now() - this._deviceListCache.lastFetch; if (ageOfCache < this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS) { log.info("Device list cache is fresh, re-using it"); return false; } } log.info("fetching updated device list"); this._fetchAndCacheDeviceListPromise = (async () => { try { const devices = await this._withVerifiedAccountState( async currentState => { const accountData = await currentState.getUserAccountData([ "sessionToken", "device", ]); const devices = await this._fxai.fxAccountsClient.getDeviceList( accountData.sessionToken ); log.info( `Got new device list: ${devices.map(d => d.id).join(", ")}` ); await this._refreshRemoteDevice(currentState, accountData, devices); return devices; } ); log.info("updating the cache"); // Be careful to only update the cache once the above has resolved, so // we know that the current account state didn't change underneath us. this._deviceListCache = { lastFetch: this._fxai.now(), devices, }; Services.obs.notifyObservers(null, ON_DEVICELIST_UPDATED); return true; } finally { this._fetchAndCacheDeviceListPromise = null; } })(); return this._fetchAndCacheDeviceListPromise; } async _refreshRemoteDevice(currentState, accountData, remoteDevices) { // Check if our push registration previously succeeded and is still // good (although background device registration means it's possible // we'll be fetching the device list before we've actually // registered ourself!) // (For a missing subscription we check for an explicit 'null' - // both to help tests and as a safety valve - missing might mean // "no push available" for self-hosters or similar?) const ourDevice = remoteDevices.find(device => device.isCurrentDevice); const subscription = await this._fxai.fxaPushService.getSubscription(); if ( ourDevice && (ourDevice.pushCallback === null || // fxa server doesn't know our subscription. ourDevice.pushEndpointExpired || // fxa server thinks it has expired. !subscription || // we don't have a local subscription. subscription.isExpired() || // our local subscription is expired. ourDevice.pushCallback != subscription.endpoint) // we don't agree with fxa. ) { log.warn(`Our push endpoint needs resubscription`); await this._fxai.fxaPushService.unsubscribe(); await this._registerOrUpdateDevice(currentState, accountData); // and there's a reasonable chance there are commands waiting. await this._fxai.commands.pollDeviceCommands(); } else if ( ourDevice && (await this._checkRemoteCommandsUpdateNeeded(ourDevice.availableCommands)) ) { log.warn(`Our commands need to be updated on the server`); await this._registerOrUpdateDevice(currentState, accountData); } else { log.trace(`Our push subscription looks OK`); } } async updateDeviceRegistration() { return this._withCurrentAccountState(async currentState => { const signedInUser = await currentState.getUserAccountData([ "sessionToken", "device", ]); if (signedInUser) { await this._registerOrUpdateDevice(currentState, signedInUser); } }); } async updateDeviceRegistrationIfNecessary() { return this._withCurrentAccountState(currentState => { return this._updateDeviceRegistrationIfNecessary(currentState); }); } reset() { this._deviceListCache = null; this._fetchAndCacheDeviceListPromise = null; } /** * Here begin our internal helper methods. * * Many of these methods take the current account state as first argument, * in order to avoid racing our state updates with e.g. the uer signing * out while we're in the middle of an update. If this does happen, the * resulting promise will be rejected rather than persisting stale state. * */ _withCurrentAccountState(func) { return this._fxai.withCurrentAccountState(async currentState => { try { return await func(currentState); } catch (err) { // `_handleTokenError` always throws, this syntax keeps the linter happy. // TODO: probably `_handleTokenError` could be done by `_fxai.withCurrentAccountState` // internally rather than us having to remember to do it here. throw await this._fxai._handleTokenError(err); } }); } _withVerifiedAccountState(func) { return this._fxai.withVerifiedAccountState(async currentState => { try { return await func(currentState); } catch (err) { // `_handleTokenError` always throws, this syntax keeps the linter happy. throw await this._fxai._handleTokenError(err); } }); } async _checkDeviceUpdateNeeded(device) { // There is no device registered or the device registration is outdated. // Either way, we should register the device with FxA // before returning the id to the caller. const availableCommandsKeys = Object.keys( await this._fxai.commands.availableCommands() ).sort(); return ( !device || !device.registrationVersion || device.registrationVersion < this.DEVICE_REGISTRATION_VERSION || !device.registeredCommandsKeys || !lazy.CommonUtils.arrayEqual( device.registeredCommandsKeys, availableCommandsKeys ) ); } async _checkRemoteCommandsUpdateNeeded(remoteAvailableCommands) { if (!remoteAvailableCommands) { return true; } const remoteAvailableCommandsKeys = Object.keys( remoteAvailableCommands ).sort(); const localAvailableCommands = await this._fxai.commands.availableCommands(); const localAvailableCommandsKeys = Object.keys( localAvailableCommands ).sort(); if ( !lazy.CommonUtils.arrayEqual( localAvailableCommandsKeys, remoteAvailableCommandsKeys ) ) { return true; } for (const key of localAvailableCommandsKeys) { if (remoteAvailableCommands[key] !== localAvailableCommands[key]) { return true; } } return false; } async _updateDeviceRegistrationIfNecessary(currentState) { let data = await currentState.getUserAccountData([ "sessionToken", "device", ]); if (!data) { // Can't register a device without a signed-in user. return null; } const { device } = data; if (await this._checkDeviceUpdateNeeded(device)) { return this._registerOrUpdateDevice(currentState, data); } // Return the device ID we already had. return device.id; } // If you change what we send to the FxA servers during device registration, // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older // devices to re-register when Firefox updates. async _registerOrUpdateDevice(currentState, signedInUser) { // This method has the side-effect of setting some account-related prefs // (e.g. for caching the device name) so it's important we don't execute it // if the signed-in state has changed. if (!currentState.isCurrent) { throw new Error( "_registerOrUpdateDevice called after a different user has signed in" ); } const { sessionToken, device: currentDevice } = signedInUser; if (!sessionToken) { throw new Error("_registerOrUpdateDevice called without a session token"); } try { const subscription = await this._fxai.fxaPushService.registerPushEndpoint(); const deviceName = this.getLocalName(); let deviceOptions = {}; // if we were able to obtain a subscription if (subscription && subscription.endpoint) { deviceOptions.pushCallback = subscription.endpoint; let publicKey = subscription.getKey("p256dh"); let authKey = subscription.getKey("auth"); if (publicKey && authKey) { deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey); deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey); } } deviceOptions.availableCommands = await this._fxai.commands.availableCommands(); const availableCommandsKeys = Object.keys( deviceOptions.availableCommands ).sort(); log.info("registering with available commands", availableCommandsKeys); let device; let is_existing = currentDevice && currentDevice.id; if (is_existing) { log.debug("updating existing device details"); device = await this._fxai.fxAccountsClient.updateDevice( sessionToken, currentDevice.id, deviceName, deviceOptions ); } else { log.debug("registering new device details"); device = await this._fxai.fxAccountsClient.registerDevice( sessionToken, deviceName, this.getLocalType(), deviceOptions ); } // Get the freshest device props before updating them. let { device: deviceProps } = await currentState.getUserAccountData([ "device", ]); await currentState.updateUserAccountData({ device: { ...deviceProps, // Copy the other properties (e.g. handledCommands). id: device.id, registrationVersion: this.DEVICE_REGISTRATION_VERSION, registeredCommandsKeys: availableCommandsKeys, }, }); // Must send the notification after we've written the storage. if (!is_existing) { Services.obs.notifyObservers(null, ON_NEW_DEVICE_ID); } return device.id; } catch (error) { return this._handleDeviceError(currentState, error, sessionToken); } } async _handleDeviceError(currentState, error, sessionToken) { try { if (error.code === 400) { if (error.errno === ERRNO_UNKNOWN_DEVICE) { return this._recoverFromUnknownDevice(currentState); } if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) { return this._recoverFromDeviceSessionConflict( currentState, error, sessionToken ); } } // `_handleTokenError` always throws, this syntax keeps the linter happy. // Note that the re-thrown error is immediately caught, logged and ignored // by the containing scope here, which is why we have to `_handleTokenError` // ourselves rather than letting it bubble up for handling by the caller. throw await this._fxai._handleTokenError(error); } catch (error) { await this._logErrorAndResetDeviceRegistrationVersion( currentState, error ); return null; } } async _recoverFromUnknownDevice(currentState) { // FxA did not recognise the device id. Handle it by clearing the device // id on the account data. At next sync or next sign-in, registration is // retried and should succeed. log.warn("unknown device id, clearing the local device data"); try { await currentState.updateUserAccountData({ device: null, encryptedSendTabKeys: null, }); } catch (error) { await this._logErrorAndResetDeviceRegistrationVersion( currentState, error ); } return null; } async _recoverFromDeviceSessionConflict(currentState, error, sessionToken) { // FxA has already associated this session with a different device id. // Perhaps we were beaten in a race to register. Handle the conflict: // 1. Fetch the list of devices for the current user from FxA. // 2. Look for ourselves in the list. // 3. If we find a match, set the correct device id and device registration // version on the account data and return the correct device id. At next // sync or next sign-in, registration is retried and should succeed. // 4. If we don't find a match, log the original error. log.warn( "device session conflict, attempting to ascertain the correct device id" ); try { const devices = await this._fxai.fxAccountsClient.getDeviceList( sessionToken ); const matchingDevices = devices.filter(device => device.isCurrentDevice); const length = matchingDevices.length; if (length === 1) { const deviceId = matchingDevices[0].id; await currentState.updateUserAccountData({ device: { id: deviceId, registrationVersion: null, }, encryptedSendTabKeys: null, }); return deviceId; } if (length > 1) { log.error( "insane server state, " + length + " devices for this session" ); } await this._logErrorAndResetDeviceRegistrationVersion( currentState, error ); } catch (secondError) { log.error("failed to recover from device-session conflict", secondError); await this._logErrorAndResetDeviceRegistrationVersion( currentState, error ); } return null; } async _logErrorAndResetDeviceRegistrationVersion(currentState, error) { // Device registration should never cause other operations to fail. // If we've reached this point, just log the error and reset the device // on the account data. At next sync or next sign-in, // registration will be retried. log.error("device registration failed", error); try { await currentState.updateUserAccountData({ device: null, encryptedSendTabKeys: null, }); } catch (secondError) { log.error( "failed to reset the device registration version, device registration won't be retried", secondError ); } } // Kick off a background refresh when a device is connected or disconnected. observe(subject, topic, data) { switch (topic) { case ON_DEVICE_CONNECTED_NOTIFICATION: this.refreshDeviceList({ ignoreCached: true }).catch(error => { log.warn( "failed to refresh devices after connecting a new device", error ); }); break; case ON_DEVICE_DISCONNECTED_NOTIFICATION: let json = JSON.parse(data); if (!json.isLocalDevice) { // If we're the device being disconnected, don't bother fetching a new // list, since our session token is now invalid. this.refreshDeviceList({ ignoreCached: true }).catch(error => { log.warn( "failed to refresh devices after disconnecting a device", error ); }); } break; case ONVERIFIED_NOTIFICATION: this.updateDeviceRegistrationIfNecessary().catch(error => { log.warn( "updateDeviceRegistrationIfNecessary failed after verification", error ); }); break; } } } FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([ "nsIObserver", "nsISupportsWeakReference", ]); function urlsafeBase64Encode(buffer) { return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false }); }