diff options
Diffstat (limited to 'services/fxaccounts/FxAccountsCommands.js')
-rw-r--r-- | services/fxaccounts/FxAccountsCommands.js | 489 |
1 files changed, 489 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsCommands.js b/services/fxaccounts/FxAccountsCommands.js new file mode 100644 index 0000000000..f199da775a --- /dev/null +++ b/services/fxaccounts/FxAccountsCommands.js @@ -0,0 +1,489 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["SendTab", "FxAccountsCommands"]; + +const { + COMMAND_SENDTAB, + COMMAND_SENDTAB_TAIL, + SCOPE_OLD_SYNC, + log, +} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "PushCrypto", + "resource://gre/modules/PushCrypto.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { Observers } = ChromeUtils.import( + "resource://services-common/observers.js" +); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BulkKeyBundle: "resource://services-sync/keys.js", + CryptoWrapper: "resource://services-sync/record.js", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "INVALID_SHAREABLE_SCHEMES", + "services.sync.engine.tabs.filteredSchemes", + "", + null, + val => { + return new Set(val.split("|")); + } +); + +class FxAccountsCommands { + constructor(fxAccountsInternal) { + this._fxai = fxAccountsInternal; + this.sendTab = new SendTab(this, fxAccountsInternal); + this._invokeRateLimitExpiry = 0; + } + + async availableCommands() { + if ( + !Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true) + ) { + return {}; + } + const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys(); + if (!encryptedSendTabKeys) { + // This will happen if the account is not verified yet. + return {}; + } + return { + [COMMAND_SENDTAB]: encryptedSendTabKeys, + }; + } + + async invoke(command, device, payload) { + const { sessionToken } = await this._fxai.getUserAccountData([ + "sessionToken", + ]); + const client = this._fxai.fxAccountsClient; + const now = Date.now(); + if (now < this._invokeRateLimitExpiry) { + const remaining = (this._invokeRateLimitExpiry - now) / 1000; + throw new Error( + `Invoke for ${command} is rate-limited for ${remaining} seconds.` + ); + } + try { + let info = await client.invokeCommand( + sessionToken, + command, + device.id, + payload + ); + if (!info.enqueued || !info.notified) { + // We want an error log here to help diagnose users who report failure. + log.error("Sending was only partially successful", info); + } else { + log.info("Successfully sent", info); + } + } catch (err) { + if (err.code && err.code === 429 && err.retryAfter) { + this._invokeRateLimitExpiry = Date.now() + err.retryAfter * 1000; + } + throw err; + } + log.info(`Payload sent to device ${device.id}.`); + } + + /** + * Poll and handle device commands for the current device. + * This method can be called either in response to a Push message, + * or by itself as a "commands recovery" mechanism. + * + * @param {Number} notifiedIndex "Command received" push messages include + * the index of the command that triggered the message. We use it as a + * hint when we have no "last command index" stored. + */ + async pollDeviceCommands(notifiedIndex = 0) { + // Whether the call to `pollDeviceCommands` was initiated by a Push message from the FxA + // servers in response to a message being received or simply scheduled in order + // to fetch missed messages. + if ( + !Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true) + ) { + return false; + } + log.info(`Polling device commands.`); + await this._fxai.withCurrentAccountState(async state => { + const { device } = await state.getUserAccountData(["device"]); + if (!device) { + throw new Error("No device registration."); + } + // We increment lastCommandIndex by 1 because the server response includes the current index. + // If we don't have a `lastCommandIndex` stored, we fall back on the index from the push message we just got. + const lastCommandIndex = device.lastCommandIndex + 1 || notifiedIndex; + // We have already received this message before. + if (notifiedIndex > 0 && notifiedIndex < lastCommandIndex) { + return; + } + const { index, messages } = await this._fetchDeviceCommands( + lastCommandIndex + ); + if (messages.length) { + await state.updateUserAccountData({ + device: { ...device, lastCommandIndex: index }, + }); + log.info(`Handling ${messages.length} messages`); + await this._handleCommands(messages, notifiedIndex); + } + }); + return true; + } + + async _fetchDeviceCommands(index, limit = null) { + const userData = await this._fxai.getUserAccountData(); + if (!userData) { + throw new Error("No user."); + } + const { sessionToken } = userData; + if (!sessionToken) { + throw new Error("No session token."); + } + const client = this._fxai.fxAccountsClient; + const opts = { index }; + if (limit != null) { + opts.limit = limit; + } + return client.getCommands(sessionToken, opts); + } + + _getReason(notifiedIndex, messageIndex) { + // The returned reason value represents an explanation for why the command associated with the + // message of the given `messageIndex` is being handled. If `notifiedIndex` is zero the command + // is a part of a fallback polling process initiated by "Sync Now" ["poll"]. If `notifiedIndex` is + // greater than `messageIndex` this is a push command that was previously missed ["push-missed"], + // otherwise we assume this is a push command with no missed messages ["push"]. + if (notifiedIndex == 0) { + return "poll"; + } else if (notifiedIndex > messageIndex) { + return "push-missed"; + } + // Note: The returned reason might be "push" in the case where a user sends multiple tabs + // in quick succession. We are not attempting to distinguish this from other push cases at + // present. + return "push"; + } + + async _handleCommands(messages, notifiedIndex) { + try { + await this._fxai.device.refreshDeviceList(); + } catch (e) { + log.warn("Error refreshing device list", e); + } + // We debounce multiple incoming tabs so we show a single notification. + const tabsReceived = []; + for (const { index, data } of messages) { + const { command, payload, sender: senderId } = data; + const reason = this._getReason(notifiedIndex, index); + const sender = + senderId && this._fxai.device.recentDeviceList + ? this._fxai.device.recentDeviceList.find(d => d.id == senderId) + : null; + if (!sender) { + log.warn( + "Incoming command is from an unknown device (maybe disconnected?)" + ); + } + switch (command) { + case COMMAND_SENDTAB: + try { + const { title, uri } = await this.sendTab.handle( + senderId, + payload, + reason + ); + log.info( + `Tab received with FxA commands: ${title} from ${ + sender ? sender.name : "Unknown device" + }.` + ); + // This should eventually be rare to hit as all platforms will be using the same + // scheme filter list, but we have this here in the case other platforms + // haven't caught up and/or trying to send invalid uris using older versions + const scheme = Services.io.newURI(uri).scheme; + if (lazy.INVALID_SHAREABLE_SCHEMES.has(scheme)) { + throw new Error("Invalid scheme found for received URI."); + } + tabsReceived.push({ title, uri, sender }); + } catch (e) { + log.error(`Error while handling incoming Send Tab payload.`, e); + } + break; + default: + log.info(`Unknown command: ${command}.`); + } + } + if (tabsReceived.length) { + this._notifyFxATabsReceived(tabsReceived); + } + } + + _notifyFxATabsReceived(tabsReceived) { + Observers.notify("fxaccounts:commands:open-uri", tabsReceived); + } +} + +/** + * Send Tab is built on top of FxA commands. + * + * Devices exchange keys wrapped in the oldsync key between themselves (getEncryptedSendTabKeys) + * during the device registration flow. The FxA server can theoretically never + * retrieve the send tab keys since it doesn't know the oldsync key. + * + * Note about the keys: + * The server has the `pushPublicKey`. The FxA server encrypt the send-tab payload again using the + * push keys - after the client has encrypted the payload using the send-tab keys. + * The push keys are different from the send-tab keys. The FxA server uses + * the push keys to deliver the tabs using same mechanism we use for web-push. + * However, clients use the send-tab keys for end-to-end encryption. + */ +class SendTab { + constructor(commands, fxAccountsInternal) { + this._commands = commands; + this._fxai = fxAccountsInternal; + } + /** + * @param {Device[]} to - Device objects (typically returned by fxAccounts.getDevicesList()). + * @param {Object} tab + * @param {string} tab.url + * @param {string} tab.title + * @returns A report object, in the shape of + * {succeded: [Device], error: [{device: Device, error: Exception}]} + */ + async send(to, tab) { + log.info(`Sending a tab to ${to.length} devices.`); + const flowID = this._fxai.telemetry.generateFlowID(); + const encoder = new TextEncoder("utf8"); + const data = { entries: [{ title: tab.title, url: tab.url }] }; + const report = { + succeeded: [], + failed: [], + }; + for (let device of to) { + try { + const streamID = this._fxai.telemetry.generateFlowID(); + const targetData = Object.assign({ flowID, streamID }, data); + const bytes = encoder.encode(JSON.stringify(targetData)); + const encrypted = await this._encrypt(bytes, device); + // 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_SENDTAB, device, payload); + this._fxai.telemetry.recordEvent( + "command-sent", + COMMAND_SENDTAB_TAIL, + this._fxai.telemetry.sanitizeDeviceId(device.id), + { flowID, streamID } + ); + report.succeeded.push(device); + } catch (error) { + log.error("Error while invoking a send tab command.", error); + report.failed.push({ device, error }); + } + } + return report; + } + + // Returns true if the target device is compatible with FxA Commands Send tab. + isDeviceCompatible(device) { + return ( + Services.prefs.getBoolPref( + "identity.fxaccounts.commands.enabled", + true + ) && + device.availableCommands && + device.availableCommands[COMMAND_SENDTAB] + ); + } + + // Handle incoming send tab payload, called by FxAccountsCommands. + async handle(senderID, { encrypted }, reason) { + const bytes = await this._decrypt(encrypted); + const decoder = new TextDecoder("utf8"); + const data = JSON.parse(decoder.decode(bytes)); + const { flowID, streamID, entries } = data; + const current = data.hasOwnProperty("current") + ? data.current + : entries.length - 1; + const { title, url: uri } = entries[current]; + // `flowID` and `streamID` are in the top-level of the JSON, `entries` is + // an array of "tabs" with `current` being what index is the one we care + // about, or the last one if not specified. + this._fxai.telemetry.recordEvent( + "command-received", + COMMAND_SENDTAB_TAIL, + this._fxai.telemetry.sanitizeDeviceId(senderID), + { flowID, streamID, reason } + ); + + return { + title, + uri, + }; + } + + async _encrypt(bytes, device) { + let bundle = device.availableCommands[COMMAND_SENDTAB]; + if (!bundle) { + throw new Error(`Device ${device.id} does not have send tab keys.`); + } + const oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC); + // Older clients expect this to be hex, due to pre-JWK sync key ids :-( + const ourKid = this._fxai.keys.kidAsHex(oldsyncKey); + const { kid: theirKid } = JSON.parse( + device.availableCommands[COMMAND_SENDTAB] + ); + if (theirKid != ourKid) { + throw new Error("Target Send Tab key ID is different from ours"); + } + 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 _getPersistedSendTabKeys() { + const { device } = await this._fxai.getUserAccountData(["device"]); + return device && device.sendTabKeys; + } + + async _decrypt(ciphertext) { + let { + privateKey, + publicKey, + authSecret, + } = await this._getPersistedSendTabKeys(); + 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 _generateAndPersistSendTabKeys() { + let [publicKey, privateKey] = await lazy.PushCrypto.generateKeys(); + publicKey = urlsafeBase64Encode(publicKey); + let authSecret = lazy.PushCrypto.generateAuthenticationSecret(); + authSecret = urlsafeBase64Encode(authSecret); + const sendTabKeys = { + publicKey, + privateKey, + authSecret, + }; + await this._fxai.withCurrentAccountState(async state => { + const { device } = await state.getUserAccountData(["device"]); + await state.updateUserAccountData({ + device: { + ...device, + sendTabKeys, + }, + }); + }); + return sendTabKeys; + } + + async _getPersistedEncryptedSendTabKey() { + const { encryptedSendTabKeys } = await this._fxai.getUserAccountData([ + "encryptedSendTabKeys", + ]); + return encryptedSendTabKeys; + } + + async _generateAndPersistEncryptedSendTabKey() { + let sendTabKeys = await this._getPersistedSendTabKeys(); + if (!sendTabKeys) { + log.info("Could not find sendtab keys, generating them"); + sendTabKeys = await this._generateAndPersistSendTabKeys(); + } + // Strip the private key from the bundle to encrypt. + const keyToEncrypt = { + publicKey: sendTabKeys.publicKey, + authSecret: sendTabKeys.authSecret, + }; + if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) { + log.info("Can't fetch keys, so unable to determine sendtab 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 sendtab keys", ex); + return null; + } + const wrapper = new lazy.CryptoWrapper(); + wrapper.cleartext = keyToEncrypt; + const keyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey); + await wrapper.encrypt(keyBundle); + const encryptedSendTabKeys = JSON.stringify({ + // Older clients expect this to be 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({ + encryptedSendTabKeys, + }); + }); + return encryptedSendTabKeys; + } + + async getEncryptedSendTabKeys() { + let encryptedSendTabKeys = await this._getPersistedEncryptedSendTabKey(); + const sendTabKeys = await this._getPersistedSendTabKeys(); + if (!encryptedSendTabKeys || !sendTabKeys) { + log.info("Generating and persisting encrypted sendtab keys"); + // `_generateAndPersistEncryptedKeys` 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) + encryptedSendTabKeys = await this._generateAndPersistEncryptedSendTabKey(); + } + return encryptedSendTabKeys; + } +} + +function urlsafeBase64Encode(buffer) { + return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false }); +} + +function urlsafeBase64Decode(str) { + return ChromeUtils.base64URLDecode(str, { padding: "reject" }); +} |