Bug 1890689 accumulate input in LargerReceiverBlockSizeThanDesiredBuffering GTest...
[gecko.git] / services / fxaccounts / FxAccountsCommands.sys.mjs
blob40fcc7f925799d7f347a2a082998202b5a8c1094
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 import {
6   COMMAND_SENDTAB,
7   COMMAND_SENDTAB_TAIL,
8   SCOPE_OLD_SYNC,
9   log,
10 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
12 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
14 import { Observers } from "resource://services-common/observers.sys.mjs";
16 const lazy = {};
18 ChromeUtils.defineESModuleGetters(lazy, {
19   BulkKeyBundle: "resource://services-sync/keys.sys.mjs",
20   CryptoWrapper: "resource://services-sync/record.sys.mjs",
21   PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs",
22 });
24 XPCOMUtils.defineLazyPreferenceGetter(
25   lazy,
26   "INVALID_SHAREABLE_SCHEMES",
27   "services.sync.engine.tabs.filteredSchemes",
28   "",
29   null,
30   val => {
31     return new Set(val.split("|"));
32   }
35 export class FxAccountsCommands {
36   constructor(fxAccountsInternal) {
37     this._fxai = fxAccountsInternal;
38     this.sendTab = new SendTab(this, fxAccountsInternal);
39     this._invokeRateLimitExpiry = 0;
40   }
42   async availableCommands() {
43     const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys();
44     if (!encryptedSendTabKeys) {
45       // This will happen if the account is not verified yet.
46       return {};
47     }
48     return {
49       [COMMAND_SENDTAB]: encryptedSendTabKeys,
50     };
51   }
53   async invoke(command, device, payload) {
54     const { sessionToken } = await this._fxai.getUserAccountData([
55       "sessionToken",
56     ]);
57     const client = this._fxai.fxAccountsClient;
58     const now = Date.now();
59     if (now < this._invokeRateLimitExpiry) {
60       const remaining = (this._invokeRateLimitExpiry - now) / 1000;
61       throw new Error(
62         `Invoke for ${command} is rate-limited for ${remaining} seconds.`
63       );
64     }
65     try {
66       let info = await client.invokeCommand(
67         sessionToken,
68         command,
69         device.id,
70         payload
71       );
72       if (!info.enqueued || !info.notified) {
73         // We want an error log here to help diagnose users who report failure.
74         log.error("Sending was only partially successful", info);
75       } else {
76         log.info("Successfully sent", info);
77       }
78     } catch (err) {
79       if (err.code && err.code === 429 && err.retryAfter) {
80         this._invokeRateLimitExpiry = Date.now() + err.retryAfter * 1000;
81       }
82       throw err;
83     }
84     log.info(`Payload sent to device ${device.id}.`);
85   }
87   /**
88    * Poll and handle device commands for the current device.
89    * This method can be called either in response to a Push message,
90    * or by itself as a "commands recovery" mechanism.
91    *
92    * @param {Number} notifiedIndex "Command received" push messages include
93    * the index of the command that triggered the message. We use it as a
94    * hint when we have no "last command index" stored.
95    */
96   async pollDeviceCommands(notifiedIndex = 0) {
97     // Whether the call to `pollDeviceCommands` was initiated by a Push message from the FxA
98     // servers in response to a message being received or simply scheduled in order
99     // to fetch missed messages.
100     log.info(`Polling device commands.`);
101     await this._fxai.withCurrentAccountState(async state => {
102       const { device } = await state.getUserAccountData(["device"]);
103       if (!device) {
104         throw new Error("No device registration.");
105       }
106       // We increment lastCommandIndex by 1 because the server response includes the current index.
107       // If we don't have a `lastCommandIndex` stored, we fall back on the index from the push message we just got.
108       const lastCommandIndex = device.lastCommandIndex + 1 || notifiedIndex;
109       // We have already received this message before.
110       if (notifiedIndex > 0 && notifiedIndex < lastCommandIndex) {
111         return;
112       }
113       const { index, messages } = await this._fetchDeviceCommands(
114         lastCommandIndex
115       );
116       if (messages.length) {
117         await state.updateUserAccountData({
118           device: { ...device, lastCommandIndex: index },
119         });
120         log.info(`Handling ${messages.length} messages`);
121         await this._handleCommands(messages, notifiedIndex);
122       }
123     });
124     return true;
125   }
127   async _fetchDeviceCommands(index, limit = null) {
128     const userData = await this._fxai.getUserAccountData();
129     if (!userData) {
130       throw new Error("No user.");
131     }
132     const { sessionToken } = userData;
133     if (!sessionToken) {
134       throw new Error("No session token.");
135     }
136     const client = this._fxai.fxAccountsClient;
137     const opts = { index };
138     if (limit != null) {
139       opts.limit = limit;
140     }
141     return client.getCommands(sessionToken, opts);
142   }
144   _getReason(notifiedIndex, messageIndex) {
145     // The returned reason value represents an explanation for why the command associated with the
146     // message of the given `messageIndex` is being handled. If `notifiedIndex` is zero the command
147     // is a part of a fallback polling process initiated by "Sync Now" ["poll"]. If `notifiedIndex` is
148     // greater than `messageIndex` this is a push command that was previously missed ["push-missed"],
149     // otherwise we assume this is a push command with no missed messages ["push"].
150     if (notifiedIndex == 0) {
151       return "poll";
152     } else if (notifiedIndex > messageIndex) {
153       return "push-missed";
154     }
155     // Note: The returned reason might be "push" in the case where a user sends multiple tabs
156     // in quick succession. We are not attempting to distinguish this from other push cases at
157     // present.
158     return "push";
159   }
161   async _handleCommands(messages, notifiedIndex) {
162     try {
163       await this._fxai.device.refreshDeviceList();
164     } catch (e) {
165       log.warn("Error refreshing device list", e);
166     }
167     // We debounce multiple incoming tabs so we show a single notification.
168     const tabsReceived = [];
169     for (const { index, data } of messages) {
170       const { command, payload, sender: senderId } = data;
171       const reason = this._getReason(notifiedIndex, index);
172       const sender =
173         senderId && this._fxai.device.recentDeviceList
174           ? this._fxai.device.recentDeviceList.find(d => d.id == senderId)
175           : null;
176       if (!sender) {
177         log.warn(
178           "Incoming command is from an unknown device (maybe disconnected?)"
179         );
180       }
181       switch (command) {
182         case COMMAND_SENDTAB:
183           try {
184             const { title, uri } = await this.sendTab.handle(
185               senderId,
186               payload,
187               reason
188             );
189             log.info(
190               `Tab received with FxA commands: "${
191                 title || "<no title>"
192               }" from ${sender ? sender.name : "Unknown device"}.`
193             );
194             // URLs are PII, so only logged at trace.
195             log.trace(`Tab received URL: ${uri}`);
196             // This should eventually be rare to hit as all platforms will be using the same
197             // scheme filter list, but we have this here in the case other platforms
198             // haven't caught up and/or trying to send invalid uris using older versions
199             const scheme = Services.io.newURI(uri).scheme;
200             if (lazy.INVALID_SHAREABLE_SCHEMES.has(scheme)) {
201               throw new Error("Invalid scheme found for received URI.");
202             }
203             tabsReceived.push({ title, uri, sender });
204           } catch (e) {
205             log.error(`Error while handling incoming Send Tab payload.`, e);
206           }
207           break;
208         default:
209           log.info(`Unknown command: ${command}.`);
210       }
211     }
212     if (tabsReceived.length) {
213       this._notifyFxATabsReceived(tabsReceived);
214     }
215   }
217   _notifyFxATabsReceived(tabsReceived) {
218     Observers.notify("fxaccounts:commands:open-uri", tabsReceived);
219   }
223  * Send Tab is built on top of FxA commands.
225  * Devices exchange keys wrapped in the oldsync key between themselves (getEncryptedSendTabKeys)
226  * during the device registration flow. The FxA server can theoretically never
227  * retrieve the send tab keys since it doesn't know the oldsync key.
229  * Note about the keys:
230  * The server has the `pushPublicKey`. The FxA server encrypt the send-tab payload again using the
231  * push keys - after the client has encrypted the payload using the send-tab keys.
232  * The push keys are different from the send-tab keys. The FxA server uses
233  * the push keys to deliver the tabs using same mechanism we use for web-push.
234  * However, clients use the send-tab keys for end-to-end encryption.
235  */
236 export class SendTab {
237   constructor(commands, fxAccountsInternal) {
238     this._commands = commands;
239     this._fxai = fxAccountsInternal;
240   }
241   /**
242    * @param {Device[]} to - Device objects (typically returned by fxAccounts.getDevicesList()).
243    * @param {Object} tab
244    * @param {string} tab.url
245    * @param {string} tab.title
246    * @returns A report object, in the shape of
247    *          {succeded: [Device], error: [{device: Device, error: Exception}]}
248    */
249   async send(to, tab) {
250     log.info(`Sending a tab to ${to.length} devices.`);
251     const flowID = this._fxai.telemetry.generateFlowID();
252     const encoder = new TextEncoder();
253     const data = { entries: [{ title: tab.title, url: tab.url }] };
254     const report = {
255       succeeded: [],
256       failed: [],
257     };
258     for (let device of to) {
259       try {
260         const streamID = this._fxai.telemetry.generateFlowID();
261         const targetData = Object.assign({ flowID, streamID }, data);
262         const bytes = encoder.encode(JSON.stringify(targetData));
263         const encrypted = await this._encrypt(bytes, device);
264         // FxA expects an object as the payload, but we only have a single encrypted string; wrap it.
265         // If you add any plaintext items to this payload, please carefully consider the privacy implications
266         // of revealing that data to the FxA server.
267         const payload = { encrypted };
268         await this._commands.invoke(COMMAND_SENDTAB, device, payload);
269         this._fxai.telemetry.recordEvent(
270           "command-sent",
271           COMMAND_SENDTAB_TAIL,
272           this._fxai.telemetry.sanitizeDeviceId(device.id),
273           { flowID, streamID }
274         );
275         report.succeeded.push(device);
276       } catch (error) {
277         log.error("Error while invoking a send tab command.", error);
278         report.failed.push({ device, error });
279       }
280     }
281     return report;
282   }
284   // Returns true if the target device is compatible with FxA Commands Send tab.
285   isDeviceCompatible(device) {
286     return (
287       device.availableCommands && device.availableCommands[COMMAND_SENDTAB]
288     );
289   }
291   // Handle incoming send tab payload, called by FxAccountsCommands.
292   async handle(senderID, { encrypted }, reason) {
293     const bytes = await this._decrypt(encrypted);
294     const decoder = new TextDecoder("utf8");
295     const data = JSON.parse(decoder.decode(bytes));
296     const { flowID, streamID, entries } = data;
297     const current = data.hasOwnProperty("current")
298       ? data.current
299       : entries.length - 1;
300     const { title, url: uri } = entries[current];
301     // `flowID` and `streamID` are in the top-level of the JSON, `entries` is
302     // an array of "tabs" with `current` being what index is the one we care
303     // about, or the last one if not specified.
304     this._fxai.telemetry.recordEvent(
305       "command-received",
306       COMMAND_SENDTAB_TAIL,
307       this._fxai.telemetry.sanitizeDeviceId(senderID),
308       { flowID, streamID, reason }
309     );
311     return {
312       title,
313       uri,
314     };
315   }
317   async _encrypt(bytes, device) {
318     let bundle = device.availableCommands[COMMAND_SENDTAB];
319     if (!bundle) {
320       throw new Error(`Device ${device.id} does not have send tab keys.`);
321     }
322     const oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
323     // Older clients expect this to be hex, due to pre-JWK sync key ids :-(
324     const ourKid = this._fxai.keys.kidAsHex(oldsyncKey);
325     const { kid: theirKid } = JSON.parse(
326       device.availableCommands[COMMAND_SENDTAB]
327     );
328     if (theirKid != ourKid) {
329       throw new Error("Target Send Tab key ID is different from ours");
330     }
331     const json = JSON.parse(bundle);
332     const wrapper = new lazy.CryptoWrapper();
333     wrapper.deserialize({ payload: json });
334     const syncKeyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey);
335     let { publicKey, authSecret } = await wrapper.decrypt(syncKeyBundle);
336     authSecret = urlsafeBase64Decode(authSecret);
337     publicKey = urlsafeBase64Decode(publicKey);
339     const { ciphertext: encrypted } = await lazy.PushCrypto.encrypt(
340       bytes,
341       publicKey,
342       authSecret
343     );
344     return urlsafeBase64Encode(encrypted);
345   }
347   async _getPersistedSendTabKeys() {
348     const { device } = await this._fxai.getUserAccountData(["device"]);
349     return device && device.sendTabKeys;
350   }
352   async _decrypt(ciphertext) {
353     let { privateKey, publicKey, authSecret } =
354       await this._getPersistedSendTabKeys();
355     publicKey = urlsafeBase64Decode(publicKey);
356     authSecret = urlsafeBase64Decode(authSecret);
357     ciphertext = new Uint8Array(urlsafeBase64Decode(ciphertext));
358     return lazy.PushCrypto.decrypt(
359       privateKey,
360       publicKey,
361       authSecret,
362       // The only Push encoding we support.
363       { encoding: "aes128gcm" },
364       ciphertext
365     );
366   }
368   async _generateAndPersistSendTabKeys() {
369     let [publicKey, privateKey] = await lazy.PushCrypto.generateKeys();
370     publicKey = urlsafeBase64Encode(publicKey);
371     let authSecret = lazy.PushCrypto.generateAuthenticationSecret();
372     authSecret = urlsafeBase64Encode(authSecret);
373     const sendTabKeys = {
374       publicKey,
375       privateKey,
376       authSecret,
377     };
378     await this._fxai.withCurrentAccountState(async state => {
379       const { device } = await state.getUserAccountData(["device"]);
380       await state.updateUserAccountData({
381         device: {
382           ...device,
383           sendTabKeys,
384         },
385       });
386     });
387     return sendTabKeys;
388   }
390   async _getPersistedEncryptedSendTabKey() {
391     const { encryptedSendTabKeys } = await this._fxai.getUserAccountData([
392       "encryptedSendTabKeys",
393     ]);
394     return encryptedSendTabKeys;
395   }
397   async _generateAndPersistEncryptedSendTabKey() {
398     let sendTabKeys = await this._getPersistedSendTabKeys();
399     if (!sendTabKeys) {
400       log.info("Could not find sendtab keys, generating them");
401       sendTabKeys = await this._generateAndPersistSendTabKeys();
402     }
403     // Strip the private key from the bundle to encrypt.
404     const keyToEncrypt = {
405       publicKey: sendTabKeys.publicKey,
406       authSecret: sendTabKeys.authSecret,
407     };
408     if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) {
409       log.info("Can't fetch keys, so unable to determine sendtab keys");
410       return null;
411     }
412     let oldsyncKey;
413     try {
414       oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
415     } catch (ex) {
416       log.warn("Failed to fetch keys, so unable to determine sendtab keys", ex);
417       return null;
418     }
419     const wrapper = new lazy.CryptoWrapper();
420     wrapper.cleartext = keyToEncrypt;
421     const keyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey);
422     await wrapper.encrypt(keyBundle);
423     const encryptedSendTabKeys = JSON.stringify({
424       // This is expected in hex, due to pre-JWK sync key ids :-(
425       kid: this._fxai.keys.kidAsHex(oldsyncKey),
426       IV: wrapper.IV,
427       hmac: wrapper.hmac,
428       ciphertext: wrapper.ciphertext,
429     });
430     await this._fxai.withCurrentAccountState(async state => {
431       await state.updateUserAccountData({
432         encryptedSendTabKeys,
433       });
434     });
435     return encryptedSendTabKeys;
436   }
438   async getEncryptedSendTabKeys() {
439     let encryptedSendTabKeys = await this._getPersistedEncryptedSendTabKey();
440     const sendTabKeys = await this._getPersistedSendTabKeys();
441     if (!encryptedSendTabKeys || !sendTabKeys) {
442       log.info("Generating and persisting encrypted sendtab keys");
443       // `_generateAndPersistEncryptedKeys` requires the sync key
444       // which cannot be accessed if the login manager is locked
445       // (i.e when the primary password is locked) or if the sync keys
446       // aren't accessible (account isn't verified)
447       // so this function could fail to retrieve the keys
448       // however, device registration will trigger when the account
449       // is verified, so it's OK
450       // Note that it's okay to persist those keys, because they are
451       // already persisted in plaintext and the encrypted bundle
452       // does not include the sync-key (the sync key is used to encrypt
453       // it though)
454       encryptedSendTabKeys =
455         await this._generateAndPersistEncryptedSendTabKey();
456     }
457     return encryptedSendTabKeys;
458   }
461 function urlsafeBase64Encode(buffer) {
462   return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
465 function urlsafeBase64Decode(str) {
466   return ChromeUtils.base64URLDecode(str, { padding: "reject" });