diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index 4bde501d9a2b..fd9216658eed 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -1934,6 +1934,10 @@ pref("identity.mobilepromo.ios", "https://www.mozilla.org/firefox/ios/?utm_sourc // Default is 24 hours. pref("identity.fxaccounts.commands.missed.fetch_interval", 86400); +// Controls whether this client can send and receive "close tab" +// commands from other FxA clients +pref("identity.fxaccounts.commands.remoteTabManagement.enabled", false); + // Note: when media.gmp-*.visible is true, provided we're running on a // supported platform/OS version, the corresponding CDM appears in the // plugins list, Firefox will download the GMP/CDM if enabled, and our diff --git a/browser/base/content/browser-sync.js b/browser/base/content/browser-sync.js index b60e82c2bcdf..efaff0ab259c 100644 --- a/browser/base/content/browser-sync.js +++ b/browser/base/content/browser-sync.js @@ -247,9 +247,30 @@ this.SyncedTabsPanelList = class SyncedTabsPanelList { if (hasNextPage) { tabs = tabs.slice(0, maxTabs); } + // We have the client obj but we need the FxA device obj so we use the clients + // engine to get us the FxA device + let device = + fxAccounts.device.recentDeviceList && + fxAccounts.device.recentDeviceList.find( + d => + d.id === Weave.Service.clientsEngine.getClientFxaDeviceId(client.id) + ); + let remoteTabCloseAvailable = + device && fxAccounts.commands.closeTab.isDeviceCompatible(device); for (let [index, tab] of tabs.entries()) { + let tabEntContainer = document.createXULElement("hbox"); + tabEntContainer.setAttribute("class", "PanelUI-tabitem-container"); + let tabEnt = this._createSyncedTabElement(tab, index); - container.appendChild(tabEnt); + tabEntContainer.appendChild(tabEnt); + // We should only add an X button next to tabs if the device + // is broadcasting that it can remotely close tabs + if (remoteTabCloseAvailable) { + tabEntContainer.appendChild( + this._createCloseTabElement(tab.url, device) + ); + } + container.appendChild(tabEntContainer); } if (numInactive) { let elt = this._createShowInactiveTabsElement( @@ -347,6 +368,20 @@ this.SyncedTabsPanelList = class SyncedTabsPanelList { return showItem; } + _createCloseTabElement(url, device) { + let closeBtn = document.createXULElement("image"); + closeBtn.setAttribute("class", "close-icon remotetabs-close"); + + closeBtn.addEventListener("click", function (e) { + e.stopPropagation(); + // The user could be hitting multiple tabs across multiple devices, with a few + // seconds in-between -- we should not immediately fire off pushes, so we + // add it to a queue and send in bulk at a later time + fxAccounts.commands.closeTab.enqueueTabToClose(device, url); + }); + return closeBtn; + } + destroy() { Services.obs.removeObserver(this, SyncedTabs.TOPIC_TABS_CHANGED); this.tabsList = null; diff --git a/browser/base/content/tabbrowser.js b/browser/base/content/tabbrowser.js index 6056d0502083..b79ea60862c0 100644 --- a/browser/base/content/tabbrowser.js +++ b/browser/base/content/tabbrowser.js @@ -4419,6 +4419,56 @@ ); } }, + /** + * Closes tabs within the browser that match a given list of nsURIs. Returns + * any nsURIs that could not be closed successfully. This does not close any + * tabs that have a beforeUnload prompt + * + * @param {nsURI[]} urisToClose + * The set of uris to remove. + * @returns {nsURI[]} + * the nsURIs that weren't found in this browser + */ + async closeTabsByURI(urisToClose) { + let remainingURIsToClose = [...urisToClose]; + let tabsToRemove = []; + for (let tab of this.tabs) { + let currentURI = tab.linkedBrowser.currentURI; + // Find any URI that matches the current tab's URI + const matchedIndex = remainingURIsToClose.findIndex(uriToClose => + uriToClose.equals(currentURI) + ); + + if (matchedIndex > -1) { + tabsToRemove.push(tab); + remainingURIsToClose.splice(matchedIndex, 1); // Remove the matched URI + } + } + + if (tabsToRemove.length) { + const { beforeUnloadComplete, lastToClose } = this._startRemoveTabs( + tabsToRemove, + { + animate: false, + suppressWarnAboutClosingWindow: true, + skipPermitUnload: false, + skipRemoves: false, + skipSessionStore: false, + } + ); + + // Wait for the beforeUnload handlers to complete. + await beforeUnloadComplete; + + // _startRemoveTabs doesn't close the last tab in the window + // for this use case, we simply close it + if (lastToClose) { + this.removeTab(lastToClose); + } + } + // If we still have uris, that means we couldn't find them in this window instance + return remainingURIsToClose; + }, /** * Handles opening a new tab with mouse middleclick. diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs index 0fae20fcd651..90bc8fcddfca 100644 --- a/browser/components/BrowserGlue.sys.mjs +++ b/browser/components/BrowserGlue.sys.mjs @@ -1149,6 +1149,9 @@ BrowserGlue.prototype = { case "fxaccounts:commands:open-uri": this._onDisplaySyncURIs(subject); break; + case "fxaccounts:commands:close-uri": + this._onIncomingCloseTabCommand(subject); + break; case "session-save": this._setPrefToSaveSession(true); subject.QueryInterface(Ci.nsISupportsPRBool); @@ -1316,6 +1319,7 @@ BrowserGlue.prototype = { "fxaccounts:verify_login", "fxaccounts:device_disconnected", "fxaccounts:commands:open-uri", + "fxaccounts:commands:close-uri", "session-save", "places-init-complete", "distribution-customization-complete", @@ -4785,6 +4789,32 @@ BrowserGlue.prototype = { } }, + async _onIncomingCloseTabCommand(data) { + // The payload is wrapped weirdly because of how Sync does notifications. + const wrappedObj = data.wrappedJSObject.object; + let { urls } = wrappedObj[0]; + let urisToClose = []; + urls.forEach(urlString => { + try { + urisToClose.push(Services.io.newURI(urlString)); + } catch (ex) { + // The url was invalid so we ignore + console.error(ex); + } + }); + for (let win of lazy.BrowserWindowTracker.orderedWindows) { + // Ensure we're operating on fully opened browser windows + if (!win.gBrowser) { + continue; + } + urisToClose = await win.gBrowser.closeTabsByURI(urisToClose); + // If we've successfully closed all the tabs, break early + if (!urisToClose.length) { + break; + } + } + }, + async _onVerifyLoginNotification({ body, title, url }) { let tab; let imageURL; diff --git a/browser/components/customizableui/test/browser_synced_tabs_menu.js b/browser/components/customizableui/test/browser_synced_tabs_menu.js index e7a674c9f0c6..c99223a80ef2 100644 --- a/browser/components/customizableui/test/browser_synced_tabs_menu.js +++ b/browser/components/customizableui/test/browser_synced_tabs_menu.js @@ -336,20 +336,28 @@ add_task(async function () { node = node.firstElementChild; is(node.getAttribute("itemtype"), "client", "node is a client entry"); is(node.textContent, "My Desktop", "correct client"); - // Next entry is the most-recent tab + // Next node is an hbox, that contains the tab and potentially + // a button for closing the tab remotely node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); - is(node.getAttribute("label"), "http://example.com/10"); + is(node.nodeName, "hbox"); + // Next entry is the most-recent tab + let childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); + is(childNode.getAttribute("label"), "http://example.com/10"); // Next entry is the next-most-recent tab node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); - is(node.getAttribute("label"), "http://example.com/5"); + is(node.nodeName, "hbox"); + childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); + is(childNode.getAttribute("label"), "http://example.com/5"); // Next entry is the least-recent tab from the first client. node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); - is(node.getAttribute("label"), "http://example.com/1"); + is(node.nodeName, "hbox"); + childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); + is(childNode.getAttribute("label"), "http://example.com/1"); node = node.nextElementSibling; is(node, null, "no more siblings"); @@ -368,8 +376,10 @@ add_task(async function () { is(node.textContent, "My Other Desktop", "correct client"); // Its single tab node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); - is(node.getAttribute("label"), "http://example.com/6"); + is(node.nodeName, "hbox"); + childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); + is(childNode.getAttribute("label"), "http://example.com/6"); node = node.nextElementSibling; is(node, null, "no more siblings"); @@ -479,14 +489,16 @@ add_task(async function () { is(node.textContent, "My Desktop", "correct client"); for (let i = 0; i < tabsShownCount; i++) { node = node.nextElementSibling; - is(node.getAttribute("itemtype"), "tab", "node is a tab"); + is(node.nodeName, "hbox"); + let childNode = node.firstElementChild; + is(childNode.getAttribute("itemtype"), "tab", "node is a tab"); is( - node.getAttribute("label"), + childNode.getAttribute("label"), "Tab #" + (i + 1), "the tab is the correct one" ); is( - node.getAttribute("targetURI"), + childNode.getAttribute("targetURI"), SAMPLE_TAB_URL, "url is the correct one" ); @@ -509,7 +521,9 @@ add_task(async function () { async function checkCanOpenURL() { let tabList = document.getElementById("PanelUI-remotetabs-tabslist"); - let node = tabList.firstElementChild.firstElementChild.nextElementSibling; + let node = + tabList.firstElementChild.firstElementChild.nextElementSibling + .firstElementChild; let promiseTabOpened = BrowserTestUtils.waitForLocationChange( gBrowser, SAMPLE_TAB_URL diff --git a/browser/themes/shared/customizableui/panelUI-shared.css b/browser/themes/shared/customizableui/panelUI-shared.css index 0f9983a34d08..478e02293db8 100644 --- a/browser/themes/shared/customizableui/panelUI-shared.css +++ b/browser/themes/shared/customizableui/panelUI-shared.css @@ -1299,7 +1299,7 @@ panelview .toolbarbutton-1 { } } -.PanelUI-remotetabs-clientcontainer > toolbarbutton[itemtype="tab"], +.PanelUI-tabitem-container > toolbarbutton[itemtype="tab"], #PanelUI-historyItems > toolbarbutton { list-style-image: url("chrome://global/skin/icons/defaultFavicon.svg"); -moz-context-properties: fill; @@ -1309,7 +1309,7 @@ panelview .toolbarbutton-1 { #fxa-menu-account-fxa-avatar, #appMenu-fxa-label > .toolbarbutton-icon, #PanelUI-containersItems > .subviewbutton > .toolbarbutton-icon, -.PanelUI-remotetabs-clientcontainer > toolbarbutton[itemtype="tab"] > .toolbarbutton-icon, +.PanelUI-tabitem-container > toolbarbutton[itemtype="tab"] > .toolbarbutton-icon, #PanelUI-recentlyClosedWindows > toolbarbutton > .toolbarbutton-icon, #PanelUI-recentlyClosedTabs > toolbarbutton > .toolbarbutton-icon, #PanelUI-historyItems > toolbarbutton > .toolbarbutton-icon { @@ -1321,6 +1321,15 @@ panelview .toolbarbutton-1 { min-width: 0; } +.PanelUI-tabitem-container > toolbarbutton[itemtype="tab"] { + flex: 1; + min-width: 0; +} + +.remotetabs-close { + width: 18px; + margin-inline-end: 4px; +} #PanelUI-fxa-menu-account-settings-button > .toolbarbutton-icon { border-radius: 50%; } diff --git a/services/fxaccounts/FxAccountsCommands.sys.mjs b/services/fxaccounts/FxAccountsCommands.sys.mjs index 40fcc7f92579..0851906061fe 100644 --- a/services/fxaccounts/FxAccountsCommands.sys.mjs +++ b/services/fxaccounts/FxAccountsCommands.sys.mjs @@ -5,10 +5,14 @@ import { COMMAND_SENDTAB, COMMAND_SENDTAB_TAIL, + COMMAND_CLOSETAB, + COMMAND_CLOSETAB_TAIL, SCOPE_OLD_SYNC, log, } from "resource://gre/modules/FxAccountsCommon.sys.mjs"; +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { Observers } from "resource://services-common/observers.sys.mjs"; @@ -36,18 +40,32 @@ export class FxAccountsCommands { constructor(fxAccountsInternal) { this._fxai = fxAccountsInternal; this.sendTab = new SendTab(this, fxAccountsInternal); + this.closeTab = new CloseRemoteTab(this, fxAccountsInternal); this._invokeRateLimitExpiry = 0; } async availableCommands() { + // Invalid keys usually means the account is not verified yet. const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys(); - if (!encryptedSendTabKeys) { - // This will happen if the account is not verified yet. - return {}; + let commands = {}; + + if (encryptedSendTabKeys) { + commands[COMMAND_SENDTAB] = encryptedSendTabKeys; } - return { - [COMMAND_SENDTAB]: encryptedSendTabKeys, - }; + + // Close Tab is still a worked-on feature, so we should not broadcast it widely yet + let closeTabEnabled = Services.prefs.getBoolPref( + "identity.fxaccounts.commands.remoteTabManagement.enabled", + false + ); + if (closeTabEnabled) { + const encryptedCloseTabKeys = + await this.closeTab.getEncryptedCloseTabKeys(); + if (encryptedCloseTabKeys) { + commands[COMMAND_CLOSETAB] = encryptedCloseTabKeys; + } + } + return commands; } async invoke(command, device, payload) { @@ -166,6 +184,7 @@ export class FxAccountsCommands { } // We debounce multiple incoming tabs so we show a single notification. const tabsReceived = []; + const tabsToClose = []; for (const { index, data } of messages) { const { command, payload, sender: senderId } = data; const reason = this._getReason(notifiedIndex, index); @@ -179,6 +198,24 @@ export class FxAccountsCommands { ); } switch (command) { + case COMMAND_CLOSETAB: + try { + const { urls } = await this.closeTab.handleTabClose( + senderId, + payload, + reason + ); + log.info( + `Close Tab received with FxA commands: "${urls.length} tabs" + from ${sender ? sender.name : "Unknown device"}.` + ); + // URLs are PII, so only logged at trace. + log.trace(`Close Remote Tabs received URLs: ${urls}`); + tabsToClose.push({ urls, sender }); + } catch (e) { + log.error(`Error while handling incoming Close Tab payload.`, e); + } + break; case COMMAND_SENDTAB: try { const { title, uri } = await this.sendTab.handle( @@ -212,11 +249,18 @@ export class FxAccountsCommands { if (tabsReceived.length) { this._notifyFxATabsReceived(tabsReceived); } + if (tabsToClose.length) { + this._notifyFxATabsClosed(tabsToClose); + } } _notifyFxATabsReceived(tabsReceived) { Observers.notify("fxaccounts:commands:open-uri", tabsReceived); } + + _notifyFxATabsClosed(tabsToClose) { + Observers.notify("fxaccounts:commands:close-uri", tabsToClose); + } } /** @@ -458,6 +502,286 @@ export class SendTab { } } +/** + * Close Tabs is built on-top of device commands and handles + * actions a client wants to perform on tabs found on other devices + * This class is very similar to the Send Tab component in FxAccountsCommands + * + * Devices exchange keys wrapped in the oldsync key between themselves (getEncryptedCloseTabKeys) + * during the device registration flow. The FxA server can theoretically never + * retrieve the close tab keys since it doesn't know the oldsync key. + * + * Note: Close Tabs does things slightly different from SendTab + * The sender encrypts the close-tab command using the receiver's public key, + * and the FxA server stores it (without re-encrypting). + * A web-push notifies the receiver that a new command is available. + * The receiver decrypts the payload using its private key. + */ +export class CloseRemoteTab { + constructor(commands, fxAccountsInternal) { + this._commands = commands; + this._fxai = fxAccountsInternal; + this.pendingClosedTabs = new Map(); + // pushes happen per device, making a timer per device makes sending + // the pushes a little more sane + this.pushTimers = new Map(); + } + + /** + * Sending a push everytime the user wants to close a tab on a remote device + * could lead to excessive notifications to the users device, push throttling, etc + * so we add the tabs to a queue and have a timer that sends the push after a certain + * amount of "inactivity" + */ + /** + * @param {Device} targetDevice - Device object (typically returned by fxAccounts.getDevicesList()). + * @param {String} tab - url for the tab to close + * @param {Integer} how far to delay, in miliseconds, the push for this timer + */ + enqueueTabToClose(targetDevice, tab, pushDelay = 6000) { + if (this.pendingClosedTabs.has(targetDevice.id)) { + this.pendingClosedTabs.get(targetDevice.id).tabs.push(tab); + } else { + this.pendingClosedTabs.set(targetDevice.id, { + device: targetDevice, + tabs: [tab], + }); + } + + // extend the timer + this._refreshPushTimer(targetDevice.id, pushDelay); + } + + async _refreshPushTimer(deviceId, pushDelay) { + // If the user is still performing "actions" for this device + // reset the timer to send the push + if (this.pushTimers.has(deviceId)) { + clearTimeout(this.pushTimers.get(deviceId)); + } + + // There is a possibility that the browser closes before this actually executes + // we should catch the browser as it's closing and immediately fire these + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1888299 + const timerId = setTimeout(async () => { + let { device, tabs } = this.pendingClosedTabs.get(deviceId); + // send a push notification for this specific device + await this._sendCloseTabPush(device, tabs); + + // Clear the timer + this.pushTimers.delete(deviceId); + // We also need to locally store the tabs we sent so the user doesn't + // see these anymore + this.pendingClosedTabs.delete(deviceId); + + // This is used for tests only, to get around timer woes + Observers.notify("test:fxaccounts:commands:close-uri:sent"); + }, pushDelay); + + // Store the new timer with the device + this.pushTimers.set(deviceId, timerId); + } + + /** + * @param {Device} target - Device object (typically returned by fxAccounts.getDevicesList()). + * @param {String[]} urls - array of urls that should be closed on the remote device + */ + async _sendCloseTabPush(target, urls) { + log.info(`Sending tab closures to ${target.id} device.`); + const flowID = this._fxai.telemetry.generateFlowID(); + const encoder = new TextEncoder(); + try { + const streamID = this._fxai.telemetry.generateFlowID(); + const targetData = { flowID, streamID, urls }; + const bytes = encoder.encode(JSON.stringify(targetData)); + const encrypted = await this._encrypt(bytes, target); + // FxA expects an object as the payload, but we only have a single encrypted string; wrap it. + // If you add any plaintext items to this payload, please carefully consider the privacy implications + // of revealing that data to the FxA server. + const payload = { encrypted }; + await this._commands.invoke(COMMAND_CLOSETAB, target, payload); + this._fxai.telemetry.recordEvent( + "command-sent", + COMMAND_CLOSETAB_TAIL, + this._fxai.telemetry.sanitizeDeviceId(target.id), + { flowID, streamID } + ); + } catch (error) { + // We should also show the user there was some kind've error + log.error("Error while invoking a send tab command.", error); + } + } + + // Returns true if the target device is compatible with FxA Commands Send tab. + isDeviceCompatible(device) { + let pref = Services.prefs.getBoolPref( + "identity.fxaccounts.commands.remoteTabManagement.enabled", + false + ); + return ( + pref && + device.availableCommands && + device.availableCommands[COMMAND_CLOSETAB] + ); + } + + // Handle incoming remote tab payload, called by FxAccountsCommands. + async handleTabClose(senderID, { encrypted }, reason) { + const bytes = await this._decrypt(encrypted); + const decoder = new TextDecoder("utf8"); + const data = JSON.parse(decoder.decode(bytes)); + // urls is an array of strings + const { flowID, streamID, urls } = data; + this._fxai.telemetry.recordEvent( + "command-received", + COMMAND_CLOSETAB_TAIL, + this._fxai.telemetry.sanitizeDeviceId(senderID), + { flowID, streamID, reason } + ); + + return { + urls, + }; + } + + async _encrypt(bytes, device) { + let bundle = device.availableCommands[COMMAND_CLOSETAB]; + if (!bundle) { + throw new Error(`Device ${device.id} does not have close tab keys.`); + } + const oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC); + const json = JSON.parse(bundle); + const wrapper = new lazy.CryptoWrapper(); + wrapper.deserialize({ payload: json }); + const syncKeyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey); + let { publicKey, authSecret } = await wrapper.decrypt(syncKeyBundle); + authSecret = urlsafeBase64Decode(authSecret); + publicKey = urlsafeBase64Decode(publicKey); + + const { ciphertext: encrypted } = await lazy.PushCrypto.encrypt( + bytes, + publicKey, + authSecret + ); + return urlsafeBase64Encode(encrypted); + } + + async _getPersistedCloseTabKeys() { + const { device } = await this._fxai.getUserAccountData(["device"]); + return device && device.closeTabKeys; + } + + async _decrypt(ciphertext) { + let { privateKey, publicKey, authSecret } = + await this._getPersistedCloseTabKeys(); + publicKey = urlsafeBase64Decode(publicKey); + authSecret = urlsafeBase64Decode(authSecret); + ciphertext = new Uint8Array(urlsafeBase64Decode(ciphertext)); + return lazy.PushCrypto.decrypt( + privateKey, + publicKey, + authSecret, + // The only Push encoding we support. + { encoding: "aes128gcm" }, + ciphertext + ); + } + + async _generateAndPersistCloseTabKeys() { + let [publicKey, privateKey] = await lazy.PushCrypto.generateKeys(); + publicKey = urlsafeBase64Encode(publicKey); + let authSecret = lazy.PushCrypto.generateAuthenticationSecret(); + authSecret = urlsafeBase64Encode(authSecret); + const closeTabKeys = { + publicKey, + privateKey, + authSecret, + }; + await this._fxai.withCurrentAccountState(async state => { + const { device } = await state.getUserAccountData(["device"]); + await state.updateUserAccountData({ + device: { + ...device, + closeTabKeys, + }, + }); + }); + return closeTabKeys; + } + + async _getPersistedEncryptedCloseTabKey() { + const { encryptedCloseTabKeys } = await this._fxai.getUserAccountData([ + "encryptedCloseTabKeys", + ]); + return encryptedCloseTabKeys; + } + + async _generateAndPersistEncryptedCloseTabKeys() { + let closeTabKeys = await this._getPersistedCloseTabKeys(); + if (!closeTabKeys) { + log.info("Could not find closeTab keys, generating them"); + closeTabKeys = await this._generateAndPersistCloseTabKeys(); + } + // Strip the private key from the bundle to encrypt. + const keyToEncrypt = { + publicKey: closeTabKeys.publicKey, + authSecret: closeTabKeys.authSecret, + }; + if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) { + log.info("Can't fetch keys, so unable to determine closeTab keys"); + return null; + } + let oldsyncKey; + try { + oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC); + } catch (ex) { + log.warn( + "Failed to fetch keys, so unable to determine closeTab keys", + ex + ); + return null; + } + const wrapper = new lazy.CryptoWrapper(); + wrapper.cleartext = keyToEncrypt; + const keyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey); + await wrapper.encrypt(keyBundle); + const encryptedCloseTabKeys = JSON.stringify({ + // This is expected in hex, due to pre-JWK sync key ids :-( + kid: this._fxai.keys.kidAsHex(oldsyncKey), + IV: wrapper.IV, + hmac: wrapper.hmac, + ciphertext: wrapper.ciphertext, + }); + await this._fxai.withCurrentAccountState(async state => { + await state.updateUserAccountData({ + encryptedCloseTabKeys, + }); + }); + return encryptedCloseTabKeys; + } + + async getEncryptedCloseTabKeys() { + let encryptedCloseTabKeys = await this._getPersistedEncryptedCloseTabKey(); + const closeTabKeys = await this._getPersistedCloseTabKeys(); + if (!encryptedCloseTabKeys || !closeTabKeys) { + log.info("Generating and persisting encrypted closeTab keys"); + // `_generateAndPersistEncryptedCloseTabKeys` requires the sync key + // which cannot be accessed if the login manager is locked + // (i.e when the primary password is locked) or if the sync keys + // aren't accessible (account isn't verified) + // so this function could fail to retrieve the keys + // however, device registration will trigger when the account + // is verified, so it's OK + // Note that it's okay to persist those keys, because they are + // already persisted in plaintext and the encrypted bundle + // does not include the sync-key (the sync key is used to encrypt + // it though) + encryptedCloseTabKeys = + await this._generateAndPersistEncryptedCloseTabKeys(); + } + return encryptedCloseTabKeys; + } +} + function urlsafeBase64Encode(buffer) { return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false }); } diff --git a/services/fxaccounts/FxAccountsCommon.sys.mjs b/services/fxaccounts/FxAccountsCommon.sys.mjs index 2688fc3c0a8f..0533b92e1479 100644 --- a/services/fxaccounts/FxAccountsCommon.sys.mjs +++ b/services/fxaccounts/FxAccountsCommon.sys.mjs @@ -77,6 +77,9 @@ export let COMMAND_PREFIX = "https://identity.mozilla.com/cmd/"; // The commands we support - only the _TAIL values are recorded in telemetry. export let COMMAND_SENDTAB_TAIL = "open-uri"; export let COMMAND_SENDTAB = COMMAND_PREFIX + COMMAND_SENDTAB_TAIL; +// A command to close a tab on this device +export let COMMAND_CLOSETAB_TAIL = "close-uri/v1"; +export let COMMAND_CLOSETAB = COMMAND_PREFIX + COMMAND_CLOSETAB_TAIL; // OAuth export let FX_OAUTH_CLIENT_ID = "5882386c6d801776"; @@ -266,6 +269,7 @@ export let FXA_PWDMGR_PLAINTEXT_FIELDS = new Set([ "device", "profileCache", "encryptedSendTabKeys", + "encryptedCloseTabKeys", ]); // Fields we store in secure storage if it exists. diff --git a/services/fxaccounts/tests/xpcshell/test_commands_closetab.js b/services/fxaccounts/tests/xpcshell/test_commands_closetab.js new file mode 100644 index 000000000000..447b80be941d --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_commands_closetab.js @@ -0,0 +1,263 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { CloseRemoteTab } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommands.sys.mjs" +); + +const { COMMAND_CLOSETAB, COMMAND_CLOSETAB_TAIL } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +class TelemetryMock { + constructor() { + this._events = []; + this._uuid_counter = 0; + } + + recordEvent(object, method, value, extra = undefined) { + this._events.push({ object, method, value, extra }); + } + + generateFlowID() { + this._uuid_counter += 1; + return this._uuid_counter.toString(); + } + + sanitizeDeviceId(id) { + return id + "-san"; + } +} + +function FxaInternalMock() { + return { + telemetry: new TelemetryMock(), + }; +} + +function promiseObserver(topic) { + return new Promise(resolve => { + let obs = (aSubject, aTopic) => { + Services.obs.removeObserver(obs, aTopic); + resolve(aSubject); + }; + Services.obs.addObserver(obs, topic); + }); +} + +add_task(async function test_closetab_isDeviceCompatible() { + const closeTab = new CloseRemoteTab(null, null); + let device = { name: "My device" }; + Assert.ok(!closeTab.isDeviceCompatible(device)); + device = { name: "My device", availableCommands: {} }; + Assert.ok(!closeTab.isDeviceCompatible(device)); + device = { + name: "My device", + availableCommands: { + "https://identity.mozilla.com/cmd/close-uri/v1": "payload", + }, + }; + // Even though the command is available, we're keeping this feature behind a feature + // flag for now, so it should still show up as "not available" + Assert.ok(!closeTab.isDeviceCompatible(device)); + + // Enable the feature + Services.prefs.setBoolPref( + "identity.fxaccounts.commands.remoteTabManagement.enabled", + true + ); + Assert.ok(closeTab.isDeviceCompatible(device)); + + // clear it for the next test + Services.prefs.clearUserPref( + "identity.fxaccounts.commands.remoteTabManagement.enabled" + ); +}); + +add_task(async function test_closetab_send() { + const commands = { + invoke: sinon.spy((cmd, device, payload) => { + Assert.equal(payload.encrypted, "encryptedpayload"); + }), + }; + const fxai = FxaInternalMock(); + const closeTab = new CloseRemoteTab(commands, fxai); + closeTab._encrypt = async () => { + return "encryptedpayload"; + }; + const targetDevice = { id: "dev1", name: "Device 1" }; + const tab = { url: "https://foo.bar/" }; + + // We add a 0 delay so we can "send" the push immediately + closeTab.enqueueTabToClose(targetDevice, tab, 0); + + // We have a tab queued + Assert.equal(closeTab.pendingClosedTabs.get(targetDevice.id).tabs.length, 1); + + // Wait on the notification to ensure the push sent + await promiseObserver("test:fxaccounts:commands:close-uri:sent"); + + // The push has been sent, we should not have the tabs anymore + Assert.equal( + closeTab.pendingClosedTabs.has(targetDevice.id), + false, + "The device should be removed from the queue after sending." + ); + + // Telemetry shows we sent one successfully + Assert.deepEqual(fxai.telemetry._events, [ + { + object: "command-sent", + method: COMMAND_CLOSETAB_TAIL, + value: "dev1-san", + // streamID uses the same generator as flowId, so it will be 2 + extra: { flowID: "1", streamID: "2" }, + }, + ]); +}); + +add_task(async function test_multiple_tabs_one_device() { + const commands = sinon.stub({ + invoke: async () => {}, + }); + const fxai = FxaInternalMock(); + const closeTab = new CloseRemoteTab(commands, fxai); + closeTab._encrypt = async () => "encryptedpayload"; + + const targetDevice = { + id: "dev1", + name: "Device 1", + availableCommands: { [COMMAND_CLOSETAB]: "payload" }, + }; + const tab1 = { url: "https://foo.bar/" }; + const tab2 = { url: "https://example.com/" }; + + closeTab.enqueueTabToClose(targetDevice, tab1, 1000); + closeTab.enqueueTabToClose(targetDevice, tab2, 0); + + // We have two tabs queued + Assert.equal(closeTab.pendingClosedTabs.get("dev1").tabs.length, 2); + + // Wait on the notification to ensure the push sent + await promiseObserver("test:fxaccounts:commands:close-uri:sent"); + + Assert.equal( + closeTab.pendingClosedTabs.has(targetDevice.id), + false, + "The device should be removed from the queue after sending." + ); + + // Telemetry shows we sent one successfully + Assert.deepEqual(fxai.telemetry._events, [ + { + object: "command-sent", + method: COMMAND_CLOSETAB_TAIL, + value: "dev1-san", + extra: { flowID: "1", streamID: "2" }, + }, + ]); +}); + +add_task(async function test_timer_reset_on_new_tab() { + const commands = sinon.stub({ + invoke: async () => {}, + }); + const fxai = FxaInternalMock(); + const closeTab = new CloseRemoteTab(commands, fxai); + closeTab._encrypt = async () => "encryptedpayload"; + + const targetDevice = { + id: "dev1", + name: "Device 1", + availableCommands: { [COMMAND_CLOSETAB]: "payload" }, + }; + const tab1 = { url: "https://foo.bar/" }; + const tab2 = { url: "https://example.com/" }; + + // default wait is 6s + closeTab.enqueueTabToClose(targetDevice, tab1); + + Assert.equal(closeTab.pendingClosedTabs.get(targetDevice.id).tabs.length, 1); + + // Adds a new tab and should reset timer + closeTab.enqueueTabToClose(targetDevice, tab2, 100); + + // We have two tabs queued + Assert.equal(closeTab.pendingClosedTabs.get(targetDevice.id).tabs.length, 2); + + // Wait on the notification to ensure the push sent + await promiseObserver("test:fxaccounts:commands:close-uri:sent"); + + // We only sent one push + sinon.assert.calledOnce(commands.invoke); + Assert.equal(closeTab.pendingClosedTabs.has(targetDevice.id), false); + + // Telemetry shows we sent only one + Assert.deepEqual(fxai.telemetry._events, [ + { + object: "command-sent", + method: COMMAND_CLOSETAB_TAIL, + value: "dev1-san", + extra: { flowID: "1", streamID: "2" }, + }, + ]); +}); + +add_task(async function test_multiple_devices() { + const commands = sinon.stub({ + invoke: async () => {}, + }); + const fxai = FxaInternalMock(); + const closeTab = new CloseRemoteTab(commands, fxai); + closeTab._encrypt = async () => "encryptedpayload"; + + const device1 = { + id: "dev1", + name: "Device 1", + availableCommands: { [COMMAND_CLOSETAB]: "payload" }, + }; + const device2 = { + id: "dev2", + name: "Device 2", + availableCommands: { [COMMAND_CLOSETAB]: "payload" }, + }; + const tab1 = { url: "https://foo.bar/" }; + const tab2 = { url: "https://example.com/" }; + + closeTab.enqueueTabToClose(device1, tab1, 100); + closeTab.enqueueTabToClose(device2, tab2, 200); + + Assert.equal(closeTab.pendingClosedTabs.get(device1.id).tabs.length, 1); + Assert.equal(closeTab.pendingClosedTabs.get(device2.id).tabs.length, 1); + + // observe the notification to ensure the push sent + await promiseObserver("test:fxaccounts:commands:close-uri:sent"); + + // We should have only sent the first device + sinon.assert.calledOnce(commands.invoke); + Assert.equal(closeTab.pendingClosedTabs.has(device1.id), false); + + // Wait on the notification to ensure the push sent + await promiseObserver("test:fxaccounts:commands:close-uri:sent"); + + // Now we've sent both pushes + sinon.assert.calledTwice(commands.invoke); + + // Two telemetry events to two different devices + Assert.deepEqual(fxai.telemetry._events, [ + { + object: "command-sent", + method: COMMAND_CLOSETAB_TAIL, + value: "dev1-san", + extra: { flowID: "1", streamID: "2" }, + }, + { + object: "command-sent", + method: COMMAND_CLOSETAB_TAIL, + value: "dev2-san", + extra: { flowID: "3", streamID: "4" }, + }, + ]); +}); diff --git a/services/fxaccounts/tests/xpcshell/xpcshell.toml b/services/fxaccounts/tests/xpcshell/xpcshell.toml index 7fc9c60006d4..09469fc0b4ca 100644 --- a/services/fxaccounts/tests/xpcshell/xpcshell.toml +++ b/services/fxaccounts/tests/xpcshell/xpcshell.toml @@ -20,6 +20,8 @@ support-files = [ ["test_commands.js"] +["test_commands_closetab.js"] + ["test_credentials.js"] ["test_device.js"]