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/. */
10 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
12 ChromeUtils.defineModuleGetter(
15 "resource://gre/modules/PushCrypto.jsm"
17 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
19 import { Observers } from "resource://services-common/observers.sys.mjs";
21 ChromeUtils.defineESModuleGetters(lazy, {
22 BulkKeyBundle: "resource://services-sync/keys.sys.mjs",
23 CryptoWrapper: "resource://services-sync/record.sys.mjs",
26 XPCOMUtils.defineLazyPreferenceGetter(
28 "INVALID_SHAREABLE_SCHEMES",
29 "services.sync.engine.tabs.filteredSchemes",
33 return new Set(val.split("|"));
37 export class FxAccountsCommands {
38 constructor(fxAccountsInternal) {
39 this._fxai = fxAccountsInternal;
40 this.sendTab = new SendTab(this, fxAccountsInternal);
41 this._invokeRateLimitExpiry = 0;
44 async availableCommands() {
46 !Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)
50 const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys();
51 if (!encryptedSendTabKeys) {
52 // This will happen if the account is not verified yet.
56 [COMMAND_SENDTAB]: encryptedSendTabKeys,
60 async invoke(command, device, payload) {
61 const { sessionToken } = await this._fxai.getUserAccountData([
64 const client = this._fxai.fxAccountsClient;
65 const now = Date.now();
66 if (now < this._invokeRateLimitExpiry) {
67 const remaining = (this._invokeRateLimitExpiry - now) / 1000;
69 `Invoke for ${command} is rate-limited for ${remaining} seconds.`
73 let info = await client.invokeCommand(
79 if (!info.enqueued || !info.notified) {
80 // We want an error log here to help diagnose users who report failure.
81 log.error("Sending was only partially successful", info);
83 log.info("Successfully sent", info);
86 if (err.code && err.code === 429 && err.retryAfter) {
87 this._invokeRateLimitExpiry = Date.now() + err.retryAfter * 1000;
91 log.info(`Payload sent to device ${device.id}.`);
95 * Poll and handle device commands for the current device.
96 * This method can be called either in response to a Push message,
97 * or by itself as a "commands recovery" mechanism.
99 * @param {Number} notifiedIndex "Command received" push messages include
100 * the index of the command that triggered the message. We use it as a
101 * hint when we have no "last command index" stored.
103 async pollDeviceCommands(notifiedIndex = 0) {
104 // Whether the call to `pollDeviceCommands` was initiated by a Push message from the FxA
105 // servers in response to a message being received or simply scheduled in order
106 // to fetch missed messages.
108 !Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)
112 log.info(`Polling device commands.`);
113 await this._fxai.withCurrentAccountState(async state => {
114 const { device } = await state.getUserAccountData(["device"]);
116 throw new Error("No device registration.");
118 // We increment lastCommandIndex by 1 because the server response includes the current index.
119 // If we don't have a `lastCommandIndex` stored, we fall back on the index from the push message we just got.
120 const lastCommandIndex = device.lastCommandIndex + 1 || notifiedIndex;
121 // We have already received this message before.
122 if (notifiedIndex > 0 && notifiedIndex < lastCommandIndex) {
125 const { index, messages } = await this._fetchDeviceCommands(
128 if (messages.length) {
129 await state.updateUserAccountData({
130 device: { ...device, lastCommandIndex: index },
132 log.info(`Handling ${messages.length} messages`);
133 await this._handleCommands(messages, notifiedIndex);
139 async _fetchDeviceCommands(index, limit = null) {
140 const userData = await this._fxai.getUserAccountData();
142 throw new Error("No user.");
144 const { sessionToken } = userData;
146 throw new Error("No session token.");
148 const client = this._fxai.fxAccountsClient;
149 const opts = { index };
153 return client.getCommands(sessionToken, opts);
156 _getReason(notifiedIndex, messageIndex) {
157 // The returned reason value represents an explanation for why the command associated with the
158 // message of the given `messageIndex` is being handled. If `notifiedIndex` is zero the command
159 // is a part of a fallback polling process initiated by "Sync Now" ["poll"]. If `notifiedIndex` is
160 // greater than `messageIndex` this is a push command that was previously missed ["push-missed"],
161 // otherwise we assume this is a push command with no missed messages ["push"].
162 if (notifiedIndex == 0) {
164 } else if (notifiedIndex > messageIndex) {
165 return "push-missed";
167 // Note: The returned reason might be "push" in the case where a user sends multiple tabs
168 // in quick succession. We are not attempting to distinguish this from other push cases at
173 async _handleCommands(messages, notifiedIndex) {
175 await this._fxai.device.refreshDeviceList();
177 log.warn("Error refreshing device list", e);
179 // We debounce multiple incoming tabs so we show a single notification.
180 const tabsReceived = [];
181 for (const { index, data } of messages) {
182 const { command, payload, sender: senderId } = data;
183 const reason = this._getReason(notifiedIndex, index);
185 senderId && this._fxai.device.recentDeviceList
186 ? this._fxai.device.recentDeviceList.find(d => d.id == senderId)
190 "Incoming command is from an unknown device (maybe disconnected?)"
194 case COMMAND_SENDTAB:
196 const { title, uri } = await this.sendTab.handle(
202 `Tab received with FxA commands: ${title} from ${
203 sender ? sender.name : "Unknown device"
206 // This should eventually be rare to hit as all platforms will be using the same
207 // scheme filter list, but we have this here in the case other platforms
208 // haven't caught up and/or trying to send invalid uris using older versions
209 const scheme = Services.io.newURI(uri).scheme;
210 if (lazy.INVALID_SHAREABLE_SCHEMES.has(scheme)) {
211 throw new Error("Invalid scheme found for received URI.");
213 tabsReceived.push({ title, uri, sender });
215 log.error(`Error while handling incoming Send Tab payload.`, e);
219 log.info(`Unknown command: ${command}.`);
222 if (tabsReceived.length) {
223 this._notifyFxATabsReceived(tabsReceived);
227 _notifyFxATabsReceived(tabsReceived) {
228 Observers.notify("fxaccounts:commands:open-uri", tabsReceived);
233 * Send Tab is built on top of FxA commands.
235 * Devices exchange keys wrapped in the oldsync key between themselves (getEncryptedSendTabKeys)
236 * during the device registration flow. The FxA server can theoretically never
237 * retrieve the send tab keys since it doesn't know the oldsync key.
239 * Note about the keys:
240 * The server has the `pushPublicKey`. The FxA server encrypt the send-tab payload again using the
241 * push keys - after the client has encrypted the payload using the send-tab keys.
242 * The push keys are different from the send-tab keys. The FxA server uses
243 * the push keys to deliver the tabs using same mechanism we use for web-push.
244 * However, clients use the send-tab keys for end-to-end encryption.
246 export class SendTab {
247 constructor(commands, fxAccountsInternal) {
248 this._commands = commands;
249 this._fxai = fxAccountsInternal;
252 * @param {Device[]} to - Device objects (typically returned by fxAccounts.getDevicesList()).
253 * @param {Object} tab
254 * @param {string} tab.url
255 * @param {string} tab.title
256 * @returns A report object, in the shape of
257 * {succeded: [Device], error: [{device: Device, error: Exception}]}
259 async send(to, tab) {
260 log.info(`Sending a tab to ${to.length} devices.`);
261 const flowID = this._fxai.telemetry.generateFlowID();
262 const encoder = new TextEncoder();
263 const data = { entries: [{ title: tab.title, url: tab.url }] };
268 for (let device of to) {
270 const streamID = this._fxai.telemetry.generateFlowID();
271 const targetData = Object.assign({ flowID, streamID }, data);
272 const bytes = encoder.encode(JSON.stringify(targetData));
273 const encrypted = await this._encrypt(bytes, device);
274 // FxA expects an object as the payload, but we only have a single encrypted string; wrap it.
275 // If you add any plaintext items to this payload, please carefully consider the privacy implications
276 // of revealing that data to the FxA server.
277 const payload = { encrypted };
278 await this._commands.invoke(COMMAND_SENDTAB, device, payload);
279 this._fxai.telemetry.recordEvent(
281 COMMAND_SENDTAB_TAIL,
282 this._fxai.telemetry.sanitizeDeviceId(device.id),
285 report.succeeded.push(device);
287 log.error("Error while invoking a send tab command.", error);
288 report.failed.push({ device, error });
294 // Returns true if the target device is compatible with FxA Commands Send tab.
295 isDeviceCompatible(device) {
297 Services.prefs.getBoolPref(
298 "identity.fxaccounts.commands.enabled",
301 device.availableCommands &&
302 device.availableCommands[COMMAND_SENDTAB]
306 // Handle incoming send tab payload, called by FxAccountsCommands.
307 async handle(senderID, { encrypted }, reason) {
308 const bytes = await this._decrypt(encrypted);
309 const decoder = new TextDecoder("utf8");
310 const data = JSON.parse(decoder.decode(bytes));
311 const { flowID, streamID, entries } = data;
312 const current = data.hasOwnProperty("current")
314 : entries.length - 1;
315 const { title, url: uri } = entries[current];
316 // `flowID` and `streamID` are in the top-level of the JSON, `entries` is
317 // an array of "tabs" with `current` being what index is the one we care
318 // about, or the last one if not specified.
319 this._fxai.telemetry.recordEvent(
321 COMMAND_SENDTAB_TAIL,
322 this._fxai.telemetry.sanitizeDeviceId(senderID),
323 { flowID, streamID, reason }
332 async _encrypt(bytes, device) {
333 let bundle = device.availableCommands[COMMAND_SENDTAB];
335 throw new Error(`Device ${device.id} does not have send tab keys.`);
337 const oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
338 // Older clients expect this to be hex, due to pre-JWK sync key ids :-(
339 const ourKid = this._fxai.keys.kidAsHex(oldsyncKey);
340 const { kid: theirKid } = JSON.parse(
341 device.availableCommands[COMMAND_SENDTAB]
343 if (theirKid != ourKid) {
344 throw new Error("Target Send Tab key ID is different from ours");
346 const json = JSON.parse(bundle);
347 const wrapper = new lazy.CryptoWrapper();
348 wrapper.deserialize({ payload: json });
349 const syncKeyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey);
350 let { publicKey, authSecret } = await wrapper.decrypt(syncKeyBundle);
351 authSecret = urlsafeBase64Decode(authSecret);
352 publicKey = urlsafeBase64Decode(publicKey);
354 const { ciphertext: encrypted } = await lazy.PushCrypto.encrypt(
359 return urlsafeBase64Encode(encrypted);
362 async _getPersistedSendTabKeys() {
363 const { device } = await this._fxai.getUserAccountData(["device"]);
364 return device && device.sendTabKeys;
367 async _decrypt(ciphertext) {
372 } = await this._getPersistedSendTabKeys();
373 publicKey = urlsafeBase64Decode(publicKey);
374 authSecret = urlsafeBase64Decode(authSecret);
375 ciphertext = new Uint8Array(urlsafeBase64Decode(ciphertext));
376 return lazy.PushCrypto.decrypt(
380 // The only Push encoding we support.
381 { encoding: "aes128gcm" },
386 async _generateAndPersistSendTabKeys() {
387 let [publicKey, privateKey] = await lazy.PushCrypto.generateKeys();
388 publicKey = urlsafeBase64Encode(publicKey);
389 let authSecret = lazy.PushCrypto.generateAuthenticationSecret();
390 authSecret = urlsafeBase64Encode(authSecret);
391 const sendTabKeys = {
396 await this._fxai.withCurrentAccountState(async state => {
397 const { device } = await state.getUserAccountData(["device"]);
398 await state.updateUserAccountData({
408 async _getPersistedEncryptedSendTabKey() {
409 const { encryptedSendTabKeys } = await this._fxai.getUserAccountData([
410 "encryptedSendTabKeys",
412 return encryptedSendTabKeys;
415 async _generateAndPersistEncryptedSendTabKey() {
416 let sendTabKeys = await this._getPersistedSendTabKeys();
418 log.info("Could not find sendtab keys, generating them");
419 sendTabKeys = await this._generateAndPersistSendTabKeys();
421 // Strip the private key from the bundle to encrypt.
422 const keyToEncrypt = {
423 publicKey: sendTabKeys.publicKey,
424 authSecret: sendTabKeys.authSecret,
426 if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) {
427 log.info("Can't fetch keys, so unable to determine sendtab keys");
432 oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
434 log.warn("Failed to fetch keys, so unable to determine sendtab keys", ex);
437 const wrapper = new lazy.CryptoWrapper();
438 wrapper.cleartext = keyToEncrypt;
439 const keyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey);
440 await wrapper.encrypt(keyBundle);
441 const encryptedSendTabKeys = JSON.stringify({
442 // Older clients expect this to be hex, due to pre-JWK sync key ids :-(
443 kid: this._fxai.keys.kidAsHex(oldsyncKey),
446 ciphertext: wrapper.ciphertext,
448 await this._fxai.withCurrentAccountState(async state => {
449 await state.updateUserAccountData({
450 encryptedSendTabKeys,
453 return encryptedSendTabKeys;
456 async getEncryptedSendTabKeys() {
457 let encryptedSendTabKeys = await this._getPersistedEncryptedSendTabKey();
458 const sendTabKeys = await this._getPersistedSendTabKeys();
459 if (!encryptedSendTabKeys || !sendTabKeys) {
460 log.info("Generating and persisting encrypted sendtab keys");
461 // `_generateAndPersistEncryptedKeys` requires the sync key
462 // which cannot be accessed if the login manager is locked
463 // (i.e when the primary password is locked) or if the sync keys
464 // aren't accessible (account isn't verified)
465 // so this function could fail to retrieve the keys
466 // however, device registration will trigger when the account
467 // is verified, so it's OK
468 // Note that it's okay to persist those keys, because they are
469 // already persisted in plaintext and the encrypted bundle
470 // does not include the sync-key (the sync key is used to encrypt
472 encryptedSendTabKeys = await this._generateAndPersistEncryptedSendTabKey();
474 return encryptedSendTabKeys;
478 function urlsafeBase64Encode(buffer) {
479 return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
482 function urlsafeBase64Decode(str) {
483 return ChromeUtils.base64URLDecode(str, { padding: "reject" });