|
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/. */ |
|
4 |
|
5 this.EXPORTED_SYMBOLS = [ |
|
6 "WBORecord", |
|
7 "RecordManager", |
|
8 "CryptoWrapper", |
|
9 "CollectionKeyManager", |
|
10 "Collection", |
|
11 ]; |
|
12 |
|
13 const Cc = Components.classes; |
|
14 const Ci = Components.interfaces; |
|
15 const Cr = Components.results; |
|
16 const Cu = Components.utils; |
|
17 |
|
18 const CRYPTO_COLLECTION = "crypto"; |
|
19 const KEYS_WBO = "keys"; |
|
20 |
|
21 Cu.import("resource://gre/modules/Log.jsm"); |
|
22 Cu.import("resource://services-sync/constants.js"); |
|
23 Cu.import("resource://services-sync/keys.js"); |
|
24 Cu.import("resource://services-sync/resource.js"); |
|
25 Cu.import("resource://services-sync/util.js"); |
|
26 |
|
27 this.WBORecord = function WBORecord(collection, id) { |
|
28 this.data = {}; |
|
29 this.payload = {}; |
|
30 this.collection = collection; // Optional. |
|
31 this.id = id; // Optional. |
|
32 } |
|
33 WBORecord.prototype = { |
|
34 _logName: "Sync.Record.WBO", |
|
35 |
|
36 get sortindex() { |
|
37 if (this.data.sortindex) |
|
38 return this.data.sortindex; |
|
39 return 0; |
|
40 }, |
|
41 |
|
42 // Get thyself from your URI, then deserialize. |
|
43 // Set thine 'response' field. |
|
44 fetch: function fetch(resource) { |
|
45 if (!resource instanceof Resource) { |
|
46 throw new Error("First argument must be a Resource instance."); |
|
47 } |
|
48 |
|
49 let r = resource.get(); |
|
50 if (r.success) { |
|
51 this.deserialize(r); // Warning! Muffles exceptions! |
|
52 } |
|
53 this.response = r; |
|
54 return this; |
|
55 }, |
|
56 |
|
57 upload: function upload(resource) { |
|
58 if (!resource instanceof Resource) { |
|
59 throw new Error("First argument must be a Resource instance."); |
|
60 } |
|
61 |
|
62 return resource.put(this); |
|
63 }, |
|
64 |
|
65 // Take a base URI string, with trailing slash, and return the URI of this |
|
66 // WBO based on collection and ID. |
|
67 uri: function(base) { |
|
68 if (this.collection && this.id) { |
|
69 let url = Utils.makeURI(base + this.collection + "/" + this.id); |
|
70 url.QueryInterface(Ci.nsIURL); |
|
71 return url; |
|
72 } |
|
73 return null; |
|
74 }, |
|
75 |
|
76 deserialize: function deserialize(json) { |
|
77 this.data = json.constructor.toString() == String ? JSON.parse(json) : json; |
|
78 |
|
79 try { |
|
80 // The payload is likely to be JSON, but if not, keep it as a string |
|
81 this.payload = JSON.parse(this.payload); |
|
82 } catch(ex) {} |
|
83 }, |
|
84 |
|
85 toJSON: function toJSON() { |
|
86 // Copy fields from data to be stringified, making sure payload is a string |
|
87 let obj = {}; |
|
88 for (let [key, val] in Iterator(this.data)) |
|
89 obj[key] = key == "payload" ? JSON.stringify(val) : val; |
|
90 if (this.ttl) |
|
91 obj.ttl = this.ttl; |
|
92 return obj; |
|
93 }, |
|
94 |
|
95 toString: function toString() { |
|
96 return "{ " + |
|
97 "id: " + this.id + " " + |
|
98 "index: " + this.sortindex + " " + |
|
99 "modified: " + this.modified + " " + |
|
100 "ttl: " + this.ttl + " " + |
|
101 "payload: " + JSON.stringify(this.payload) + |
|
102 " }"; |
|
103 } |
|
104 }; |
|
105 |
|
106 Utils.deferGetSet(WBORecord, "data", ["id", "modified", "sortindex", "payload"]); |
|
107 |
|
108 /** |
|
109 * An interface and caching layer for records. |
|
110 */ |
|
111 this.RecordManager = function RecordManager(service) { |
|
112 this.service = service; |
|
113 |
|
114 this._log = Log.repository.getLogger(this._logName); |
|
115 this._records = {}; |
|
116 } |
|
117 RecordManager.prototype = { |
|
118 _recordType: WBORecord, |
|
119 _logName: "Sync.RecordManager", |
|
120 |
|
121 import: function RecordMgr_import(url) { |
|
122 this._log.trace("Importing record: " + (url.spec ? url.spec : url)); |
|
123 try { |
|
124 // Clear out the last response with empty object if GET fails |
|
125 this.response = {}; |
|
126 this.response = this.service.resource(url).get(); |
|
127 |
|
128 // Don't parse and save the record on failure |
|
129 if (!this.response.success) |
|
130 return null; |
|
131 |
|
132 let record = new this._recordType(url); |
|
133 record.deserialize(this.response); |
|
134 |
|
135 return this.set(url, record); |
|
136 } catch(ex) { |
|
137 this._log.debug("Failed to import record: " + Utils.exceptionStr(ex)); |
|
138 return null; |
|
139 } |
|
140 }, |
|
141 |
|
142 get: function RecordMgr_get(url) { |
|
143 // Use a url string as the key to the hash |
|
144 let spec = url.spec ? url.spec : url; |
|
145 if (spec in this._records) |
|
146 return this._records[spec]; |
|
147 return this.import(url); |
|
148 }, |
|
149 |
|
150 set: function RecordMgr_set(url, record) { |
|
151 let spec = url.spec ? url.spec : url; |
|
152 return this._records[spec] = record; |
|
153 }, |
|
154 |
|
155 contains: function RecordMgr_contains(url) { |
|
156 if ((url.spec || url) in this._records) |
|
157 return true; |
|
158 return false; |
|
159 }, |
|
160 |
|
161 clearCache: function recordMgr_clearCache() { |
|
162 this._records = {}; |
|
163 }, |
|
164 |
|
165 del: function RecordMgr_del(url) { |
|
166 delete this._records[url]; |
|
167 } |
|
168 }; |
|
169 |
|
170 this.CryptoWrapper = function CryptoWrapper(collection, id) { |
|
171 this.cleartext = {}; |
|
172 WBORecord.call(this, collection, id); |
|
173 this.ciphertext = null; |
|
174 this.id = id; |
|
175 } |
|
176 CryptoWrapper.prototype = { |
|
177 __proto__: WBORecord.prototype, |
|
178 _logName: "Sync.Record.CryptoWrapper", |
|
179 |
|
180 ciphertextHMAC: function ciphertextHMAC(keyBundle) { |
|
181 let hasher = keyBundle.sha256HMACHasher; |
|
182 if (!hasher) { |
|
183 throw "Cannot compute HMAC without an HMAC key."; |
|
184 } |
|
185 |
|
186 return Utils.bytesAsHex(Utils.digestUTF8(this.ciphertext, hasher)); |
|
187 }, |
|
188 |
|
189 /* |
|
190 * Don't directly use the sync key. Instead, grab a key for this |
|
191 * collection, which is decrypted with the sync key. |
|
192 * |
|
193 * Cache those keys; invalidate the cache if the time on the keys collection |
|
194 * changes, or other auth events occur. |
|
195 * |
|
196 * Optional key bundle overrides the collection key lookup. |
|
197 */ |
|
198 encrypt: function encrypt(keyBundle) { |
|
199 if (!keyBundle) { |
|
200 throw new Error("A key bundle must be supplied to encrypt."); |
|
201 } |
|
202 |
|
203 this.IV = Svc.Crypto.generateRandomIV(); |
|
204 this.ciphertext = Svc.Crypto.encrypt(JSON.stringify(this.cleartext), |
|
205 keyBundle.encryptionKeyB64, this.IV); |
|
206 this.hmac = this.ciphertextHMAC(keyBundle); |
|
207 this.cleartext = null; |
|
208 }, |
|
209 |
|
210 // Optional key bundle. |
|
211 decrypt: function decrypt(keyBundle) { |
|
212 if (!this.ciphertext) { |
|
213 throw "No ciphertext: nothing to decrypt?"; |
|
214 } |
|
215 |
|
216 if (!keyBundle) { |
|
217 throw new Error("A key bundle must be supplied to decrypt."); |
|
218 } |
|
219 |
|
220 // Authenticate the encrypted blob with the expected HMAC |
|
221 let computedHMAC = this.ciphertextHMAC(keyBundle); |
|
222 |
|
223 if (computedHMAC != this.hmac) { |
|
224 Utils.throwHMACMismatch(this.hmac, computedHMAC); |
|
225 } |
|
226 |
|
227 // Handle invalid data here. Elsewhere we assume that cleartext is an object. |
|
228 let cleartext = Svc.Crypto.decrypt(this.ciphertext, |
|
229 keyBundle.encryptionKeyB64, this.IV); |
|
230 let json_result = JSON.parse(cleartext); |
|
231 |
|
232 if (json_result && (json_result instanceof Object)) { |
|
233 this.cleartext = json_result; |
|
234 this.ciphertext = null; |
|
235 } else { |
|
236 throw "Decryption failed: result is <" + json_result + ">, not an object."; |
|
237 } |
|
238 |
|
239 // Verify that the encrypted id matches the requested record's id. |
|
240 if (this.cleartext.id != this.id) |
|
241 throw "Record id mismatch: " + this.cleartext.id + " != " + this.id; |
|
242 |
|
243 return this.cleartext; |
|
244 }, |
|
245 |
|
246 toString: function toString() { |
|
247 let payload = this.deleted ? "DELETED" : JSON.stringify(this.cleartext); |
|
248 |
|
249 return "{ " + |
|
250 "id: " + this.id + " " + |
|
251 "index: " + this.sortindex + " " + |
|
252 "modified: " + this.modified + " " + |
|
253 "ttl: " + this.ttl + " " + |
|
254 "payload: " + payload + " " + |
|
255 "collection: " + (this.collection || "undefined") + |
|
256 " }"; |
|
257 }, |
|
258 |
|
259 // The custom setter below masks the parent's getter, so explicitly call it :( |
|
260 get id() WBORecord.prototype.__lookupGetter__("id").call(this), |
|
261 |
|
262 // Keep both plaintext and encrypted versions of the id to verify integrity |
|
263 set id(val) { |
|
264 WBORecord.prototype.__lookupSetter__("id").call(this, val); |
|
265 return this.cleartext.id = val; |
|
266 }, |
|
267 }; |
|
268 |
|
269 Utils.deferGetSet(CryptoWrapper, "payload", ["ciphertext", "IV", "hmac"]); |
|
270 Utils.deferGetSet(CryptoWrapper, "cleartext", "deleted"); |
|
271 |
|
272 |
|
273 /** |
|
274 * Keeps track of mappings between collection names ('tabs') and KeyBundles. |
|
275 * |
|
276 * You can update this thing simply by giving it /info/collections. It'll |
|
277 * use the last modified time to bring itself up to date. |
|
278 */ |
|
279 this.CollectionKeyManager = function CollectionKeyManager() { |
|
280 this.lastModified = 0; |
|
281 this._collections = {}; |
|
282 this._default = null; |
|
283 |
|
284 this._log = Log.repository.getLogger("Sync.CollectionKeyManager"); |
|
285 } |
|
286 |
|
287 // TODO: persist this locally as an Identity. Bug 610913. |
|
288 // Note that the last modified time needs to be preserved. |
|
289 CollectionKeyManager.prototype = { |
|
290 |
|
291 // Return information about old vs new keys: |
|
292 // * same: true if two collections are equal |
|
293 // * changed: an array of collection names that changed. |
|
294 _compareKeyBundleCollections: function _compareKeyBundleCollections(m1, m2) { |
|
295 let changed = []; |
|
296 |
|
297 function process(m1, m2) { |
|
298 for (let k1 in m1) { |
|
299 let v1 = m1[k1]; |
|
300 let v2 = m2[k1]; |
|
301 if (!(v1 && v2 && v1.equals(v2))) |
|
302 changed.push(k1); |
|
303 } |
|
304 } |
|
305 |
|
306 // Diffs both ways. |
|
307 process(m1, m2); |
|
308 process(m2, m1); |
|
309 |
|
310 // Return a sorted, unique array. |
|
311 changed.sort(); |
|
312 let last; |
|
313 changed = [x for each (x in changed) if ((x != last) && (last = x))]; |
|
314 return {same: changed.length == 0, |
|
315 changed: changed}; |
|
316 }, |
|
317 |
|
318 get isClear() { |
|
319 return !this._default; |
|
320 }, |
|
321 |
|
322 clear: function clear() { |
|
323 this._log.info("Clearing collection keys..."); |
|
324 this.lastModified = 0; |
|
325 this._collections = {}; |
|
326 this._default = null; |
|
327 }, |
|
328 |
|
329 keyForCollection: function(collection) { |
|
330 if (collection && this._collections[collection]) |
|
331 return this._collections[collection]; |
|
332 |
|
333 return this._default; |
|
334 }, |
|
335 |
|
336 /** |
|
337 * If `collections` (an array of strings) is provided, iterate |
|
338 * over it and generate random keys for each collection. |
|
339 * Create a WBO for the given data. |
|
340 */ |
|
341 _makeWBO: function(collections, defaultBundle) { |
|
342 let wbo = new CryptoWrapper(CRYPTO_COLLECTION, KEYS_WBO); |
|
343 let c = {}; |
|
344 for (let k in collections) { |
|
345 c[k] = collections[k].keyPairB64; |
|
346 } |
|
347 wbo.cleartext = { |
|
348 "default": defaultBundle ? defaultBundle.keyPairB64 : null, |
|
349 "collections": c, |
|
350 "collection": CRYPTO_COLLECTION, |
|
351 "id": KEYS_WBO |
|
352 }; |
|
353 return wbo; |
|
354 }, |
|
355 |
|
356 /** |
|
357 * Create a WBO for the current keys. |
|
358 */ |
|
359 asWBO: function(collection, id) |
|
360 this._makeWBO(this._collections, this._default), |
|
361 |
|
362 /** |
|
363 * Compute a new default key, and new keys for any specified collections. |
|
364 */ |
|
365 newKeys: function(collections) { |
|
366 let newDefaultKey = new BulkKeyBundle(DEFAULT_KEYBUNDLE_NAME); |
|
367 newDefaultKey.generateRandom(); |
|
368 |
|
369 let newColls = {}; |
|
370 if (collections) { |
|
371 collections.forEach(function (c) { |
|
372 let b = new BulkKeyBundle(c); |
|
373 b.generateRandom(); |
|
374 newColls[c] = b; |
|
375 }); |
|
376 } |
|
377 return [newDefaultKey, newColls]; |
|
378 }, |
|
379 |
|
380 /** |
|
381 * Generates new keys, but does not replace our local copy. Use this to |
|
382 * verify an upload before storing. |
|
383 */ |
|
384 generateNewKeysWBO: function(collections) { |
|
385 let newDefaultKey, newColls; |
|
386 [newDefaultKey, newColls] = this.newKeys(collections); |
|
387 |
|
388 return this._makeWBO(newColls, newDefaultKey); |
|
389 }, |
|
390 |
|
391 // Take the fetched info/collections WBO, checking the change |
|
392 // time of the crypto collection. |
|
393 updateNeeded: function(info_collections) { |
|
394 |
|
395 this._log.info("Testing for updateNeeded. Last modified: " + this.lastModified); |
|
396 |
|
397 // No local record of modification time? Need an update. |
|
398 if (!this.lastModified) |
|
399 return true; |
|
400 |
|
401 // No keys on the server? We need an update, though our |
|
402 // update handling will be a little more drastic... |
|
403 if (!(CRYPTO_COLLECTION in info_collections)) |
|
404 return true; |
|
405 |
|
406 // Otherwise, we need an update if our modification time is stale. |
|
407 return (info_collections[CRYPTO_COLLECTION] > this.lastModified); |
|
408 }, |
|
409 |
|
410 // |
|
411 // Set our keys and modified time to the values fetched from the server. |
|
412 // Returns one of three values: |
|
413 // |
|
414 // * If the default key was modified, return true. |
|
415 // * If the default key was not modified, but per-collection keys were, |
|
416 // return an array of such. |
|
417 // * Otherwise, return false -- we were up-to-date. |
|
418 // |
|
419 setContents: function setContents(payload, modified) { |
|
420 |
|
421 if (!modified) |
|
422 throw "No modified time provided to setContents."; |
|
423 |
|
424 let self = this; |
|
425 |
|
426 this._log.info("Setting collection keys contents. Our last modified: " + |
|
427 this.lastModified + ", input modified: " + modified + "."); |
|
428 |
|
429 if (!payload) |
|
430 throw "No payload in CollectionKeyManager.setContents()."; |
|
431 |
|
432 if (!payload.default) { |
|
433 this._log.warn("No downloaded default key: this should not occur."); |
|
434 this._log.warn("Not clearing local keys."); |
|
435 throw "No default key in CollectionKeyManager.setContents(). Cannot proceed."; |
|
436 } |
|
437 |
|
438 // Process the incoming default key. |
|
439 let b = new BulkKeyBundle(DEFAULT_KEYBUNDLE_NAME); |
|
440 b.keyPairB64 = payload.default; |
|
441 let newDefault = b; |
|
442 |
|
443 // Process the incoming collections. |
|
444 let newCollections = {}; |
|
445 if ("collections" in payload) { |
|
446 this._log.info("Processing downloaded per-collection keys."); |
|
447 let colls = payload.collections; |
|
448 for (let k in colls) { |
|
449 let v = colls[k]; |
|
450 if (v) { |
|
451 let keyObj = new BulkKeyBundle(k); |
|
452 keyObj.keyPairB64 = v; |
|
453 if (keyObj) { |
|
454 newCollections[k] = keyObj; |
|
455 } |
|
456 } |
|
457 } |
|
458 } |
|
459 |
|
460 // Check to see if these are already our keys. |
|
461 let sameDefault = (this._default && this._default.equals(newDefault)); |
|
462 let collComparison = this._compareKeyBundleCollections(newCollections, this._collections); |
|
463 let sameColls = collComparison.same; |
|
464 |
|
465 if (sameDefault && sameColls) { |
|
466 self._log.info("New keys are the same as our old keys! Bumped local modified time."); |
|
467 self.lastModified = modified; |
|
468 return false; |
|
469 } |
|
470 |
|
471 // Make sure things are nice and tidy before we set. |
|
472 this.clear(); |
|
473 |
|
474 this._log.info("Saving downloaded keys."); |
|
475 this._default = newDefault; |
|
476 this._collections = newCollections; |
|
477 |
|
478 // Always trust the server. |
|
479 self._log.info("Bumping last modified to " + modified); |
|
480 self.lastModified = modified; |
|
481 |
|
482 return sameDefault ? collComparison.changed : true; |
|
483 }, |
|
484 |
|
485 updateContents: function updateContents(syncKeyBundle, storage_keys) { |
|
486 let log = this._log; |
|
487 log.info("Updating collection keys..."); |
|
488 |
|
489 // storage_keys is a WBO, fetched from storage/crypto/keys. |
|
490 // Its payload is the default key, and a map of collections to keys. |
|
491 // We lazily compute the key objects from the strings we're given. |
|
492 |
|
493 let payload; |
|
494 try { |
|
495 payload = storage_keys.decrypt(syncKeyBundle); |
|
496 } catch (ex) { |
|
497 log.warn("Got exception \"" + ex + "\" decrypting storage keys with sync key."); |
|
498 log.info("Aborting updateContents. Rethrowing."); |
|
499 throw ex; |
|
500 } |
|
501 |
|
502 let r = this.setContents(payload, storage_keys.modified); |
|
503 log.info("Collection keys updated."); |
|
504 return r; |
|
505 } |
|
506 } |
|
507 |
|
508 this.Collection = function Collection(uri, recordObj, service) { |
|
509 if (!service) { |
|
510 throw new Error("Collection constructor requires a service."); |
|
511 } |
|
512 |
|
513 Resource.call(this, uri); |
|
514 |
|
515 // This is a bit hacky, but gets the job done. |
|
516 let res = service.resource(uri); |
|
517 this.authenticator = res.authenticator; |
|
518 |
|
519 this._recordObj = recordObj; |
|
520 this._service = service; |
|
521 |
|
522 this._full = false; |
|
523 this._ids = null; |
|
524 this._limit = 0; |
|
525 this._older = 0; |
|
526 this._newer = 0; |
|
527 this._data = []; |
|
528 } |
|
529 Collection.prototype = { |
|
530 __proto__: Resource.prototype, |
|
531 _logName: "Sync.Collection", |
|
532 |
|
533 _rebuildURL: function Coll__rebuildURL() { |
|
534 // XXX should consider what happens if it's not a URL... |
|
535 this.uri.QueryInterface(Ci.nsIURL); |
|
536 |
|
537 let args = []; |
|
538 if (this.older) |
|
539 args.push('older=' + this.older); |
|
540 else if (this.newer) { |
|
541 args.push('newer=' + this.newer); |
|
542 } |
|
543 if (this.full) |
|
544 args.push('full=1'); |
|
545 if (this.sort) |
|
546 args.push('sort=' + this.sort); |
|
547 if (this.ids != null) |
|
548 args.push("ids=" + this.ids); |
|
549 if (this.limit > 0 && this.limit != Infinity) |
|
550 args.push("limit=" + this.limit); |
|
551 |
|
552 this.uri.query = (args.length > 0)? '?' + args.join('&') : ''; |
|
553 }, |
|
554 |
|
555 // get full items |
|
556 get full() { return this._full; }, |
|
557 set full(value) { |
|
558 this._full = value; |
|
559 this._rebuildURL(); |
|
560 }, |
|
561 |
|
562 // Apply the action to a certain set of ids |
|
563 get ids() this._ids, |
|
564 set ids(value) { |
|
565 this._ids = value; |
|
566 this._rebuildURL(); |
|
567 }, |
|
568 |
|
569 // Limit how many records to get |
|
570 get limit() this._limit, |
|
571 set limit(value) { |
|
572 this._limit = value; |
|
573 this._rebuildURL(); |
|
574 }, |
|
575 |
|
576 // get only items modified before some date |
|
577 get older() { return this._older; }, |
|
578 set older(value) { |
|
579 this._older = value; |
|
580 this._rebuildURL(); |
|
581 }, |
|
582 |
|
583 // get only items modified since some date |
|
584 get newer() { return this._newer; }, |
|
585 set newer(value) { |
|
586 this._newer = value; |
|
587 this._rebuildURL(); |
|
588 }, |
|
589 |
|
590 // get items sorted by some criteria. valid values: |
|
591 // oldest (oldest first) |
|
592 // newest (newest first) |
|
593 // index |
|
594 get sort() { return this._sort; }, |
|
595 set sort(value) { |
|
596 this._sort = value; |
|
597 this._rebuildURL(); |
|
598 }, |
|
599 |
|
600 pushData: function Coll_pushData(data) { |
|
601 this._data.push(data); |
|
602 }, |
|
603 |
|
604 clearRecords: function Coll_clearRecords() { |
|
605 this._data = []; |
|
606 }, |
|
607 |
|
608 set recordHandler(onRecord) { |
|
609 // Save this because onProgress is called with this as the ChannelListener |
|
610 let coll = this; |
|
611 |
|
612 // Switch to newline separated records for incremental parsing |
|
613 coll.setHeader("Accept", "application/newlines"); |
|
614 |
|
615 this._onProgress = function() { |
|
616 let newline; |
|
617 while ((newline = this._data.indexOf("\n")) > 0) { |
|
618 // Split the json record from the rest of the data |
|
619 let json = this._data.slice(0, newline); |
|
620 this._data = this._data.slice(newline + 1); |
|
621 |
|
622 // Deserialize a record from json and give it to the callback |
|
623 let record = new coll._recordObj(); |
|
624 record.deserialize(json); |
|
625 onRecord(record); |
|
626 } |
|
627 }; |
|
628 }, |
|
629 }; |