summaryrefslogtreecommitdiffstats
path: root/services/sync/modules/engines/extension-storage.js
blob: f8f15b1283228df25ef68fadf2dba869576b0d72 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
/* 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/. */

"use strict";

this.EXPORTED_SYMBOLS = ['ExtensionStorageEngine', 'EncryptionRemoteTransformer',
                         'KeyRingEncryptionRemoteTransformer'];

const {classes: Cc, interfaces: Ci, utils: Cu} = Components;

Cu.import("resource://services-crypto/utils.js");
Cu.import("resource://services-sync/constants.js");
Cu.import("resource://services-sync/engines.js");
Cu.import("resource://services-sync/keys.js");
Cu.import("resource://services-sync/util.js");
Cu.import("resource://services-common/async.js");
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorageSync",
                                  "resource://gre/modules/ExtensionStorageSync.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
                                  "resource://gre/modules/FxAccounts.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                  "resource://gre/modules/Task.jsm");

/**
 * The Engine that manages syncing for the web extension "storage"
 * API, and in particular ext.storage.sync.
 *
 * ext.storage.sync is implemented using Kinto, so it has mechanisms
 * for syncing that we do not need to integrate in the Firefox Sync
 * framework, so this is something of a stub.
 */
this.ExtensionStorageEngine = function ExtensionStorageEngine(service) {
  SyncEngine.call(this, "Extension-Storage", service);
};
ExtensionStorageEngine.prototype = {
  __proto__: SyncEngine.prototype,
  _trackerObj: ExtensionStorageTracker,
  // we don't need these since we implement our own sync logic
  _storeObj: undefined,
  _recordObj: undefined,

  syncPriority: 10,
  allowSkippedRecord: false,

  _sync: function () {
    return Async.promiseSpinningly(ExtensionStorageSync.syncAll());
  },

  get enabled() {
    // By default, we sync extension storage if we sync addons. This
    // lets us simplify the UX since users probably don't consider
    // "extension preferences" a separate category of syncing.
    // However, we also respect engine.extension-storage.force, which
    // can be set to true or false, if a power user wants to customize
    // the behavior despite the lack of UI.
    const forced = Svc.Prefs.get("engine." + this.prefName + ".force", undefined);
    if (forced !== undefined) {
      return forced;
    }
    return Svc.Prefs.get("engine.addons", false);
  },
};

function ExtensionStorageTracker(name, engine) {
  Tracker.call(this, name, engine);
}
ExtensionStorageTracker.prototype = {
  __proto__: Tracker.prototype,

  startTracking: function () {
    Svc.Obs.add("ext.storage.sync-changed", this);
  },

  stopTracking: function () {
    Svc.Obs.remove("ext.storage.sync-changed", this);
  },

  observe: function (subject, topic, data) {
    Tracker.prototype.observe.call(this, subject, topic, data);

    if (this.ignoreAll) {
      return;
    }

    if (topic !== "ext.storage.sync-changed") {
      return;
    }

    // Single adds, removes and changes are not so important on their
    // own, so let's just increment score a bit.
    this.score += SCORE_INCREMENT_MEDIUM;
  },

  // Override a bunch of methods which don't do anything for us.
  // This is a performance hack.
  saveChangedIDs: function() {
  },
  loadChangedIDs: function() {
  },
  ignoreID: function() {
  },
  unignoreID: function() {
  },
  addChangedID: function() {
  },
  removeChangedID: function() {
  },
  clearChangedIDs: function() {
  },
};

/**
 * Utility function to enforce an order of fields when computing an HMAC.
 */
function ciphertextHMAC(keyBundle, id, IV, ciphertext) {
  const hasher = keyBundle.sha256HMACHasher;
  return Utils.bytesAsHex(Utils.digestUTF8(id + IV + ciphertext, hasher));
}

/**
 * A "remote transformer" that the Kinto library will use to
 * encrypt/decrypt records when syncing.
 *
 * This is an "abstract base class". Subclass this and override
 * getKeys() to use it.
 */
class EncryptionRemoteTransformer {
  encode(record) {
    const self = this;
    return Task.spawn(function* () {
      const keyBundle = yield self.getKeys();
      if (record.ciphertext) {
        throw new Error("Attempt to reencrypt??");
      }
      let id = record.id;
      if (!record.id) {
        throw new Error("Record ID is missing or invalid");
      }

      let IV = Svc.Crypto.generateRandomIV();
      let ciphertext = Svc.Crypto.encrypt(JSON.stringify(record),
                                          keyBundle.encryptionKeyB64, IV);
      let hmac = ciphertextHMAC(keyBundle, id, IV, ciphertext);
      const encryptedResult = {ciphertext, IV, hmac, id};
      if (record.hasOwnProperty("last_modified")) {
        encryptedResult.last_modified = record.last_modified;
      }
      return encryptedResult;
    });
  }

  decode(record) {
    const self = this;
    return Task.spawn(function* () {
      if (!record.ciphertext) {
        // This can happen for tombstones if a record is deleted.
        if (record.deleted) {
          return record;
        }
        throw new Error("No ciphertext: nothing to decrypt?");
      }
      const keyBundle = yield self.getKeys();
      // Authenticate the encrypted blob with the expected HMAC
      let computedHMAC = ciphertextHMAC(keyBundle, record.id, record.IV, record.ciphertext);

      if (computedHMAC != record.hmac) {
        Utils.throwHMACMismatch(record.hmac, computedHMAC);
      }

      // Handle invalid data here. Elsewhere we assume that cleartext is an object.
      let cleartext = Svc.Crypto.decrypt(record.ciphertext,
                                         keyBundle.encryptionKeyB64, record.IV);
      let jsonResult = JSON.parse(cleartext);
      if (!jsonResult || typeof jsonResult !== "object") {
        throw new Error("Decryption failed: result is <" + jsonResult + ">, not an object.");
      }

      // Verify that the encrypted id matches the requested record's id.
      // This should always be true, because we compute the HMAC over
      // the original record's ID, and that was verified already (above).
      if (jsonResult.id != record.id) {
        throw new Error("Record id mismatch: " + jsonResult.id + " != " + record.id);
      }

      if (record.hasOwnProperty("last_modified")) {
        jsonResult.last_modified = record.last_modified;
      }

      return jsonResult;
    });
  }

  /**
   * Retrieve keys to use during encryption.
   *
   * Returns a Promise<KeyBundle>.
   */
  getKeys() {
    throw new Error("override getKeys in a subclass");
  }
}
// You can inject this
EncryptionRemoteTransformer.prototype._fxaService = fxAccounts;

/**
 * An EncryptionRemoteTransformer that provides a keybundle derived
 * from the user's kB, suitable for encrypting a keyring.
 */
class KeyRingEncryptionRemoteTransformer extends EncryptionRemoteTransformer {
  getKeys() {
    const self = this;
    return Task.spawn(function* () {
      const user = yield self._fxaService.getSignedInUser();
      // FIXME: we should permit this if the user is self-hosting
      // their storage
      if (!user) {
        throw new Error("user isn't signed in to FxA; can't sync");
      }

      if (!user.kB) {
        throw new Error("user doesn't have kB");
      }

      let kB = Utils.hexToBytes(user.kB);

      let keyMaterial = CryptoUtils.hkdf(kB, undefined,
                                       "identity.mozilla.com/picl/v1/chrome.storage.sync", 2*32);
      let bundle = new BulkKeyBundle();
      // [encryptionKey, hmacKey]
      bundle.keyPair = [keyMaterial.slice(0, 32), keyMaterial.slice(32, 64)];
      return bundle;
    });
  }
  // Pass through the kbHash field from the unencrypted record. If
  // encryption fails, we can use this to try to detect whether we are
  // being compromised or if the record here was encoded with a
  // different kB.
  encode(record) {
    const encodePromise = super.encode(record);
    return Task.spawn(function* () {
      const encoded = yield encodePromise;
      encoded.kbHash = record.kbHash;
      return encoded;
    });
  }

  decode(record) {
    const decodePromise = super.decode(record);
    return Task.spawn(function* () {
      try {
        return yield decodePromise;
      } catch (e) {
        if (Utils.isHMACMismatch(e)) {
          const currentKBHash = yield ExtensionStorageSync.getKBHash();
          if (record.kbHash != currentKBHash) {
            // Some other client encoded this with a kB that we don't
            // have access to.
            KeyRingEncryptionRemoteTransformer.throwOutdatedKB(currentKBHash, record.kbHash);
          }
        }
        throw e;
      }
    });
  }

  // Generator and discriminator for KB-is-outdated exceptions.
  static throwOutdatedKB(shouldBe, is) {
    throw new Error(`kB hash on record is outdated: should be ${shouldBe}, is ${is}`);
  }

  static isOutdatedKB(exc) {
    const kbMessage = "kB hash on record is outdated: ";
    return exc && exc.message && exc.message.indexOf &&
      (exc.message.indexOf(kbMessage) == 0);
  }
}