Bug 1814091 - Move CanvasContext.getPreferredFormat to GPU.getPreferredCanvasFormat...
[gecko.git] / services / fxaccounts / FxAccountsCommands.sys.mjs
blob212d8daf5cc2f0c083b7e69d3dbb64efcf55255e
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/. */
5 const {
6   COMMAND_SENDTAB,
7   COMMAND_SENDTAB_TAIL,
8   SCOPE_OLD_SYNC,
9   log,
10 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
11 const lazy = {};
12 ChromeUtils.defineModuleGetter(
13   lazy,
14   "PushCrypto",
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",
24 });
26 XPCOMUtils.defineLazyPreferenceGetter(
27   lazy,
28   "INVALID_SHAREABLE_SCHEMES",
29   "services.sync.engine.tabs.filteredSchemes",
30   "",
31   null,
32   val => {
33     return new Set(val.split("|"));
34   }
37 export class FxAccountsCommands {
38   constructor(fxAccountsInternal) {
39     this._fxai = fxAccountsInternal;
40     this.sendTab = new SendTab(this, fxAccountsInternal);
41     this._invokeRateLimitExpiry = 0;
42   }
44   async availableCommands() {
45     if (
46       !Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)
47     ) {
48       return {};
49     }
50     const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys();
51     if (!encryptedSendTabKeys) {
52       // This will happen if the account is not verified yet.
53       return {};
54     }
55     return {
56       [COMMAND_SENDTAB]: encryptedSendTabKeys,
57     };
58   }
60   async invoke(command, device, payload) {
61     const { sessionToken } = await this._fxai.getUserAccountData([
62       "sessionToken",
63     ]);
64     const client = this._fxai.fxAccountsClient;
65     const now = Date.now();
66     if (now < this._invokeRateLimitExpiry) {
67       const remaining = (this._invokeRateLimitExpiry - now) / 1000;
68       throw new Error(
69         `Invoke for ${command} is rate-limited for ${remaining} seconds.`
70       );
71     }
72     try {
73       let info = await client.invokeCommand(
74         sessionToken,
75         command,
76         device.id,
77         payload
78       );
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);
82       } else {
83         log.info("Successfully sent", info);
84       }
85     } catch (err) {
86       if (err.code && err.code === 429 && err.retryAfter) {
87         this._invokeRateLimitExpiry = Date.now() + err.retryAfter * 1000;
88       }
89       throw err;
90     }
91     log.info(`Payload sent to device ${device.id}.`);
92   }
94   /**
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.
98    *
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.
102    */
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.
107     if (
108       !Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)
109     ) {
110       return false;
111     }
112     log.info(`Polling device commands.`);
113     await this._fxai.withCurrentAccountState(async state => {
114       const { device } = await state.getUserAccountData(["device"]);
115       if (!device) {
116         throw new Error("No device registration.");
117       }
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) {
123         return;
124       }
125       const { index, messages } = await this._fetchDeviceCommands(
126         lastCommandIndex
127       );
128       if (messages.length) {
129         await state.updateUserAccountData({
130           device: { ...device, lastCommandIndex: index },
131         });
132         log.info(`Handling ${messages.length} messages`);
133         await this._handleCommands(messages, notifiedIndex);
134       }
135     });
136     return true;
137   }
139   async _fetchDeviceCommands(index, limit = null) {
140     const userData = await this._fxai.getUserAccountData();
141     if (!userData) {
142       throw new Error("No user.");
143     }
144     const { sessionToken } = userData;
145     if (!sessionToken) {
146       throw new Error("No session token.");
147     }
148     const client = this._fxai.fxAccountsClient;
149     const opts = { index };
150     if (limit != null) {
151       opts.limit = limit;
152     }
153     return client.getCommands(sessionToken, opts);
154   }
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) {
163       return "poll";
164     } else if (notifiedIndex > messageIndex) {
165       return "push-missed";
166     }
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
169     // present.
170     return "push";
171   }
173   async _handleCommands(messages, notifiedIndex) {
174     try {
175       await this._fxai.device.refreshDeviceList();
176     } catch (e) {
177       log.warn("Error refreshing device list", e);
178     }
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);
184       const sender =
185         senderId && this._fxai.device.recentDeviceList
186           ? this._fxai.device.recentDeviceList.find(d => d.id == senderId)
187           : null;
188       if (!sender) {
189         log.warn(
190           "Incoming command is from an unknown device (maybe disconnected?)"
191         );
192       }
193       switch (command) {
194         case COMMAND_SENDTAB:
195           try {
196             const { title, uri } = await this.sendTab.handle(
197               senderId,
198               payload,
199               reason
200             );
201             log.info(
202               `Tab received with FxA commands: ${title} from ${
203                 sender ? sender.name : "Unknown device"
204               }.`
205             );
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.");
212             }
213             tabsReceived.push({ title, uri, sender });
214           } catch (e) {
215             log.error(`Error while handling incoming Send Tab payload.`, e);
216           }
217           break;
218         default:
219           log.info(`Unknown command: ${command}.`);
220       }
221     }
222     if (tabsReceived.length) {
223       this._notifyFxATabsReceived(tabsReceived);
224     }
225   }
227   _notifyFxATabsReceived(tabsReceived) {
228     Observers.notify("fxaccounts:commands:open-uri", tabsReceived);
229   }
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.
245  */
246 export class SendTab {
247   constructor(commands, fxAccountsInternal) {
248     this._commands = commands;
249     this._fxai = fxAccountsInternal;
250   }
251   /**
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}]}
258    */
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 }] };
264     const report = {
265       succeeded: [],
266       failed: [],
267     };
268     for (let device of to) {
269       try {
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(
280           "command-sent",
281           COMMAND_SENDTAB_TAIL,
282           this._fxai.telemetry.sanitizeDeviceId(device.id),
283           { flowID, streamID }
284         );
285         report.succeeded.push(device);
286       } catch (error) {
287         log.error("Error while invoking a send tab command.", error);
288         report.failed.push({ device, error });
289       }
290     }
291     return report;
292   }
294   // Returns true if the target device is compatible with FxA Commands Send tab.
295   isDeviceCompatible(device) {
296     return (
297       Services.prefs.getBoolPref(
298         "identity.fxaccounts.commands.enabled",
299         true
300       ) &&
301       device.availableCommands &&
302       device.availableCommands[COMMAND_SENDTAB]
303     );
304   }
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")
313       ? data.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(
320       "command-received",
321       COMMAND_SENDTAB_TAIL,
322       this._fxai.telemetry.sanitizeDeviceId(senderID),
323       { flowID, streamID, reason }
324     );
326     return {
327       title,
328       uri,
329     };
330   }
332   async _encrypt(bytes, device) {
333     let bundle = device.availableCommands[COMMAND_SENDTAB];
334     if (!bundle) {
335       throw new Error(`Device ${device.id} does not have send tab keys.`);
336     }
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]
342     );
343     if (theirKid != ourKid) {
344       throw new Error("Target Send Tab key ID is different from ours");
345     }
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(
355       bytes,
356       publicKey,
357       authSecret
358     );
359     return urlsafeBase64Encode(encrypted);
360   }
362   async _getPersistedSendTabKeys() {
363     const { device } = await this._fxai.getUserAccountData(["device"]);
364     return device && device.sendTabKeys;
365   }
367   async _decrypt(ciphertext) {
368     let {
369       privateKey,
370       publicKey,
371       authSecret,
372     } = await this._getPersistedSendTabKeys();
373     publicKey = urlsafeBase64Decode(publicKey);
374     authSecret = urlsafeBase64Decode(authSecret);
375     ciphertext = new Uint8Array(urlsafeBase64Decode(ciphertext));
376     return lazy.PushCrypto.decrypt(
377       privateKey,
378       publicKey,
379       authSecret,
380       // The only Push encoding we support.
381       { encoding: "aes128gcm" },
382       ciphertext
383     );
384   }
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 = {
392       publicKey,
393       privateKey,
394       authSecret,
395     };
396     await this._fxai.withCurrentAccountState(async state => {
397       const { device } = await state.getUserAccountData(["device"]);
398       await state.updateUserAccountData({
399         device: {
400           ...device,
401           sendTabKeys,
402         },
403       });
404     });
405     return sendTabKeys;
406   }
408   async _getPersistedEncryptedSendTabKey() {
409     const { encryptedSendTabKeys } = await this._fxai.getUserAccountData([
410       "encryptedSendTabKeys",
411     ]);
412     return encryptedSendTabKeys;
413   }
415   async _generateAndPersistEncryptedSendTabKey() {
416     let sendTabKeys = await this._getPersistedSendTabKeys();
417     if (!sendTabKeys) {
418       log.info("Could not find sendtab keys, generating them");
419       sendTabKeys = await this._generateAndPersistSendTabKeys();
420     }
421     // Strip the private key from the bundle to encrypt.
422     const keyToEncrypt = {
423       publicKey: sendTabKeys.publicKey,
424       authSecret: sendTabKeys.authSecret,
425     };
426     if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) {
427       log.info("Can't fetch keys, so unable to determine sendtab keys");
428       return null;
429     }
430     let oldsyncKey;
431     try {
432       oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
433     } catch (ex) {
434       log.warn("Failed to fetch keys, so unable to determine sendtab keys", ex);
435       return null;
436     }
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),
444       IV: wrapper.IV,
445       hmac: wrapper.hmac,
446       ciphertext: wrapper.ciphertext,
447     });
448     await this._fxai.withCurrentAccountState(async state => {
449       await state.updateUserAccountData({
450         encryptedSendTabKeys,
451       });
452     });
453     return encryptedSendTabKeys;
454   }
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
471       // it though)
472       encryptedSendTabKeys = await this._generateAndPersistEncryptedSendTabKey();
473     }
474     return encryptedSendTabKeys;
475   }
478 function urlsafeBase64Encode(buffer) {
479   return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
482 function urlsafeBase64Decode(str) {
483   return ChromeUtils.base64URLDecode(str, { padding: "reject" });