michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: this.EXPORTED_SYMBOLS = [ michael@0: "WBORecord", michael@0: "RecordManager", michael@0: "CryptoWrapper", michael@0: "CollectionKeyManager", michael@0: "Collection", michael@0: ]; michael@0: michael@0: const Cc = Components.classes; michael@0: const Ci = Components.interfaces; michael@0: const Cr = Components.results; michael@0: const Cu = Components.utils; michael@0: michael@0: const CRYPTO_COLLECTION = "crypto"; michael@0: const KEYS_WBO = "keys"; michael@0: michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://services-sync/constants.js"); michael@0: Cu.import("resource://services-sync/keys.js"); michael@0: Cu.import("resource://services-sync/resource.js"); michael@0: Cu.import("resource://services-sync/util.js"); michael@0: michael@0: this.WBORecord = function WBORecord(collection, id) { michael@0: this.data = {}; michael@0: this.payload = {}; michael@0: this.collection = collection; // Optional. michael@0: this.id = id; // Optional. michael@0: } michael@0: WBORecord.prototype = { michael@0: _logName: "Sync.Record.WBO", michael@0: michael@0: get sortindex() { michael@0: if (this.data.sortindex) michael@0: return this.data.sortindex; michael@0: return 0; michael@0: }, michael@0: michael@0: // Get thyself from your URI, then deserialize. michael@0: // Set thine 'response' field. michael@0: fetch: function fetch(resource) { michael@0: if (!resource instanceof Resource) { michael@0: throw new Error("First argument must be a Resource instance."); michael@0: } michael@0: michael@0: let r = resource.get(); michael@0: if (r.success) { michael@0: this.deserialize(r); // Warning! Muffles exceptions! michael@0: } michael@0: this.response = r; michael@0: return this; michael@0: }, michael@0: michael@0: upload: function upload(resource) { michael@0: if (!resource instanceof Resource) { michael@0: throw new Error("First argument must be a Resource instance."); michael@0: } michael@0: michael@0: return resource.put(this); michael@0: }, michael@0: michael@0: // Take a base URI string, with trailing slash, and return the URI of this michael@0: // WBO based on collection and ID. michael@0: uri: function(base) { michael@0: if (this.collection && this.id) { michael@0: let url = Utils.makeURI(base + this.collection + "/" + this.id); michael@0: url.QueryInterface(Ci.nsIURL); michael@0: return url; michael@0: } michael@0: return null; michael@0: }, michael@0: michael@0: deserialize: function deserialize(json) { michael@0: this.data = json.constructor.toString() == String ? JSON.parse(json) : json; michael@0: michael@0: try { michael@0: // The payload is likely to be JSON, but if not, keep it as a string michael@0: this.payload = JSON.parse(this.payload); michael@0: } catch(ex) {} michael@0: }, michael@0: michael@0: toJSON: function toJSON() { michael@0: // Copy fields from data to be stringified, making sure payload is a string michael@0: let obj = {}; michael@0: for (let [key, val] in Iterator(this.data)) michael@0: obj[key] = key == "payload" ? JSON.stringify(val) : val; michael@0: if (this.ttl) michael@0: obj.ttl = this.ttl; michael@0: return obj; michael@0: }, michael@0: michael@0: toString: function toString() { michael@0: return "{ " + michael@0: "id: " + this.id + " " + michael@0: "index: " + this.sortindex + " " + michael@0: "modified: " + this.modified + " " + michael@0: "ttl: " + this.ttl + " " + michael@0: "payload: " + JSON.stringify(this.payload) + michael@0: " }"; michael@0: } michael@0: }; michael@0: michael@0: Utils.deferGetSet(WBORecord, "data", ["id", "modified", "sortindex", "payload"]); michael@0: michael@0: /** michael@0: * An interface and caching layer for records. michael@0: */ michael@0: this.RecordManager = function RecordManager(service) { michael@0: this.service = service; michael@0: michael@0: this._log = Log.repository.getLogger(this._logName); michael@0: this._records = {}; michael@0: } michael@0: RecordManager.prototype = { michael@0: _recordType: WBORecord, michael@0: _logName: "Sync.RecordManager", michael@0: michael@0: import: function RecordMgr_import(url) { michael@0: this._log.trace("Importing record: " + (url.spec ? url.spec : url)); michael@0: try { michael@0: // Clear out the last response with empty object if GET fails michael@0: this.response = {}; michael@0: this.response = this.service.resource(url).get(); michael@0: michael@0: // Don't parse and save the record on failure michael@0: if (!this.response.success) michael@0: return null; michael@0: michael@0: let record = new this._recordType(url); michael@0: record.deserialize(this.response); michael@0: michael@0: return this.set(url, record); michael@0: } catch(ex) { michael@0: this._log.debug("Failed to import record: " + Utils.exceptionStr(ex)); michael@0: return null; michael@0: } michael@0: }, michael@0: michael@0: get: function RecordMgr_get(url) { michael@0: // Use a url string as the key to the hash michael@0: let spec = url.spec ? url.spec : url; michael@0: if (spec in this._records) michael@0: return this._records[spec]; michael@0: return this.import(url); michael@0: }, michael@0: michael@0: set: function RecordMgr_set(url, record) { michael@0: let spec = url.spec ? url.spec : url; michael@0: return this._records[spec] = record; michael@0: }, michael@0: michael@0: contains: function RecordMgr_contains(url) { michael@0: if ((url.spec || url) in this._records) michael@0: return true; michael@0: return false; michael@0: }, michael@0: michael@0: clearCache: function recordMgr_clearCache() { michael@0: this._records = {}; michael@0: }, michael@0: michael@0: del: function RecordMgr_del(url) { michael@0: delete this._records[url]; michael@0: } michael@0: }; michael@0: michael@0: this.CryptoWrapper = function CryptoWrapper(collection, id) { michael@0: this.cleartext = {}; michael@0: WBORecord.call(this, collection, id); michael@0: this.ciphertext = null; michael@0: this.id = id; michael@0: } michael@0: CryptoWrapper.prototype = { michael@0: __proto__: WBORecord.prototype, michael@0: _logName: "Sync.Record.CryptoWrapper", michael@0: michael@0: ciphertextHMAC: function ciphertextHMAC(keyBundle) { michael@0: let hasher = keyBundle.sha256HMACHasher; michael@0: if (!hasher) { michael@0: throw "Cannot compute HMAC without an HMAC key."; michael@0: } michael@0: michael@0: return Utils.bytesAsHex(Utils.digestUTF8(this.ciphertext, hasher)); michael@0: }, michael@0: michael@0: /* michael@0: * Don't directly use the sync key. Instead, grab a key for this michael@0: * collection, which is decrypted with the sync key. michael@0: * michael@0: * Cache those keys; invalidate the cache if the time on the keys collection michael@0: * changes, or other auth events occur. michael@0: * michael@0: * Optional key bundle overrides the collection key lookup. michael@0: */ michael@0: encrypt: function encrypt(keyBundle) { michael@0: if (!keyBundle) { michael@0: throw new Error("A key bundle must be supplied to encrypt."); michael@0: } michael@0: michael@0: this.IV = Svc.Crypto.generateRandomIV(); michael@0: this.ciphertext = Svc.Crypto.encrypt(JSON.stringify(this.cleartext), michael@0: keyBundle.encryptionKeyB64, this.IV); michael@0: this.hmac = this.ciphertextHMAC(keyBundle); michael@0: this.cleartext = null; michael@0: }, michael@0: michael@0: // Optional key bundle. michael@0: decrypt: function decrypt(keyBundle) { michael@0: if (!this.ciphertext) { michael@0: throw "No ciphertext: nothing to decrypt?"; michael@0: } michael@0: michael@0: if (!keyBundle) { michael@0: throw new Error("A key bundle must be supplied to decrypt."); michael@0: } michael@0: michael@0: // Authenticate the encrypted blob with the expected HMAC michael@0: let computedHMAC = this.ciphertextHMAC(keyBundle); michael@0: michael@0: if (computedHMAC != this.hmac) { michael@0: Utils.throwHMACMismatch(this.hmac, computedHMAC); michael@0: } michael@0: michael@0: // Handle invalid data here. Elsewhere we assume that cleartext is an object. michael@0: let cleartext = Svc.Crypto.decrypt(this.ciphertext, michael@0: keyBundle.encryptionKeyB64, this.IV); michael@0: let json_result = JSON.parse(cleartext); michael@0: michael@0: if (json_result && (json_result instanceof Object)) { michael@0: this.cleartext = json_result; michael@0: this.ciphertext = null; michael@0: } else { michael@0: throw "Decryption failed: result is <" + json_result + ">, not an object."; michael@0: } michael@0: michael@0: // Verify that the encrypted id matches the requested record's id. michael@0: if (this.cleartext.id != this.id) michael@0: throw "Record id mismatch: " + this.cleartext.id + " != " + this.id; michael@0: michael@0: return this.cleartext; michael@0: }, michael@0: michael@0: toString: function toString() { michael@0: let payload = this.deleted ? "DELETED" : JSON.stringify(this.cleartext); michael@0: michael@0: return "{ " + michael@0: "id: " + this.id + " " + michael@0: "index: " + this.sortindex + " " + michael@0: "modified: " + this.modified + " " + michael@0: "ttl: " + this.ttl + " " + michael@0: "payload: " + payload + " " + michael@0: "collection: " + (this.collection || "undefined") + michael@0: " }"; michael@0: }, michael@0: michael@0: // The custom setter below masks the parent's getter, so explicitly call it :( michael@0: get id() WBORecord.prototype.__lookupGetter__("id").call(this), michael@0: michael@0: // Keep both plaintext and encrypted versions of the id to verify integrity michael@0: set id(val) { michael@0: WBORecord.prototype.__lookupSetter__("id").call(this, val); michael@0: return this.cleartext.id = val; michael@0: }, michael@0: }; michael@0: michael@0: Utils.deferGetSet(CryptoWrapper, "payload", ["ciphertext", "IV", "hmac"]); michael@0: Utils.deferGetSet(CryptoWrapper, "cleartext", "deleted"); michael@0: michael@0: michael@0: /** michael@0: * Keeps track of mappings between collection names ('tabs') and KeyBundles. michael@0: * michael@0: * You can update this thing simply by giving it /info/collections. It'll michael@0: * use the last modified time to bring itself up to date. michael@0: */ michael@0: this.CollectionKeyManager = function CollectionKeyManager() { michael@0: this.lastModified = 0; michael@0: this._collections = {}; michael@0: this._default = null; michael@0: michael@0: this._log = Log.repository.getLogger("Sync.CollectionKeyManager"); michael@0: } michael@0: michael@0: // TODO: persist this locally as an Identity. Bug 610913. michael@0: // Note that the last modified time needs to be preserved. michael@0: CollectionKeyManager.prototype = { michael@0: michael@0: // Return information about old vs new keys: michael@0: // * same: true if two collections are equal michael@0: // * changed: an array of collection names that changed. michael@0: _compareKeyBundleCollections: function _compareKeyBundleCollections(m1, m2) { michael@0: let changed = []; michael@0: michael@0: function process(m1, m2) { michael@0: for (let k1 in m1) { michael@0: let v1 = m1[k1]; michael@0: let v2 = m2[k1]; michael@0: if (!(v1 && v2 && v1.equals(v2))) michael@0: changed.push(k1); michael@0: } michael@0: } michael@0: michael@0: // Diffs both ways. michael@0: process(m1, m2); michael@0: process(m2, m1); michael@0: michael@0: // Return a sorted, unique array. michael@0: changed.sort(); michael@0: let last; michael@0: changed = [x for each (x in changed) if ((x != last) && (last = x))]; michael@0: return {same: changed.length == 0, michael@0: changed: changed}; michael@0: }, michael@0: michael@0: get isClear() { michael@0: return !this._default; michael@0: }, michael@0: michael@0: clear: function clear() { michael@0: this._log.info("Clearing collection keys..."); michael@0: this.lastModified = 0; michael@0: this._collections = {}; michael@0: this._default = null; michael@0: }, michael@0: michael@0: keyForCollection: function(collection) { michael@0: if (collection && this._collections[collection]) michael@0: return this._collections[collection]; michael@0: michael@0: return this._default; michael@0: }, michael@0: michael@0: /** michael@0: * If `collections` (an array of strings) is provided, iterate michael@0: * over it and generate random keys for each collection. michael@0: * Create a WBO for the given data. michael@0: */ michael@0: _makeWBO: function(collections, defaultBundle) { michael@0: let wbo = new CryptoWrapper(CRYPTO_COLLECTION, KEYS_WBO); michael@0: let c = {}; michael@0: for (let k in collections) { michael@0: c[k] = collections[k].keyPairB64; michael@0: } michael@0: wbo.cleartext = { michael@0: "default": defaultBundle ? defaultBundle.keyPairB64 : null, michael@0: "collections": c, michael@0: "collection": CRYPTO_COLLECTION, michael@0: "id": KEYS_WBO michael@0: }; michael@0: return wbo; michael@0: }, michael@0: michael@0: /** michael@0: * Create a WBO for the current keys. michael@0: */ michael@0: asWBO: function(collection, id) michael@0: this._makeWBO(this._collections, this._default), michael@0: michael@0: /** michael@0: * Compute a new default key, and new keys for any specified collections. michael@0: */ michael@0: newKeys: function(collections) { michael@0: let newDefaultKey = new BulkKeyBundle(DEFAULT_KEYBUNDLE_NAME); michael@0: newDefaultKey.generateRandom(); michael@0: michael@0: let newColls = {}; michael@0: if (collections) { michael@0: collections.forEach(function (c) { michael@0: let b = new BulkKeyBundle(c); michael@0: b.generateRandom(); michael@0: newColls[c] = b; michael@0: }); michael@0: } michael@0: return [newDefaultKey, newColls]; michael@0: }, michael@0: michael@0: /** michael@0: * Generates new keys, but does not replace our local copy. Use this to michael@0: * verify an upload before storing. michael@0: */ michael@0: generateNewKeysWBO: function(collections) { michael@0: let newDefaultKey, newColls; michael@0: [newDefaultKey, newColls] = this.newKeys(collections); michael@0: michael@0: return this._makeWBO(newColls, newDefaultKey); michael@0: }, michael@0: michael@0: // Take the fetched info/collections WBO, checking the change michael@0: // time of the crypto collection. michael@0: updateNeeded: function(info_collections) { michael@0: michael@0: this._log.info("Testing for updateNeeded. Last modified: " + this.lastModified); michael@0: michael@0: // No local record of modification time? Need an update. michael@0: if (!this.lastModified) michael@0: return true; michael@0: michael@0: // No keys on the server? We need an update, though our michael@0: // update handling will be a little more drastic... michael@0: if (!(CRYPTO_COLLECTION in info_collections)) michael@0: return true; michael@0: michael@0: // Otherwise, we need an update if our modification time is stale. michael@0: return (info_collections[CRYPTO_COLLECTION] > this.lastModified); michael@0: }, michael@0: michael@0: // michael@0: // Set our keys and modified time to the values fetched from the server. michael@0: // Returns one of three values: michael@0: // michael@0: // * If the default key was modified, return true. michael@0: // * If the default key was not modified, but per-collection keys were, michael@0: // return an array of such. michael@0: // * Otherwise, return false -- we were up-to-date. michael@0: // michael@0: setContents: function setContents(payload, modified) { michael@0: michael@0: if (!modified) michael@0: throw "No modified time provided to setContents."; michael@0: michael@0: let self = this; michael@0: michael@0: this._log.info("Setting collection keys contents. Our last modified: " + michael@0: this.lastModified + ", input modified: " + modified + "."); michael@0: michael@0: if (!payload) michael@0: throw "No payload in CollectionKeyManager.setContents()."; michael@0: michael@0: if (!payload.default) { michael@0: this._log.warn("No downloaded default key: this should not occur."); michael@0: this._log.warn("Not clearing local keys."); michael@0: throw "No default key in CollectionKeyManager.setContents(). Cannot proceed."; michael@0: } michael@0: michael@0: // Process the incoming default key. michael@0: let b = new BulkKeyBundle(DEFAULT_KEYBUNDLE_NAME); michael@0: b.keyPairB64 = payload.default; michael@0: let newDefault = b; michael@0: michael@0: // Process the incoming collections. michael@0: let newCollections = {}; michael@0: if ("collections" in payload) { michael@0: this._log.info("Processing downloaded per-collection keys."); michael@0: let colls = payload.collections; michael@0: for (let k in colls) { michael@0: let v = colls[k]; michael@0: if (v) { michael@0: let keyObj = new BulkKeyBundle(k); michael@0: keyObj.keyPairB64 = v; michael@0: if (keyObj) { michael@0: newCollections[k] = keyObj; michael@0: } michael@0: } michael@0: } michael@0: } michael@0: michael@0: // Check to see if these are already our keys. michael@0: let sameDefault = (this._default && this._default.equals(newDefault)); michael@0: let collComparison = this._compareKeyBundleCollections(newCollections, this._collections); michael@0: let sameColls = collComparison.same; michael@0: michael@0: if (sameDefault && sameColls) { michael@0: self._log.info("New keys are the same as our old keys! Bumped local modified time."); michael@0: self.lastModified = modified; michael@0: return false; michael@0: } michael@0: michael@0: // Make sure things are nice and tidy before we set. michael@0: this.clear(); michael@0: michael@0: this._log.info("Saving downloaded keys."); michael@0: this._default = newDefault; michael@0: this._collections = newCollections; michael@0: michael@0: // Always trust the server. michael@0: self._log.info("Bumping last modified to " + modified); michael@0: self.lastModified = modified; michael@0: michael@0: return sameDefault ? collComparison.changed : true; michael@0: }, michael@0: michael@0: updateContents: function updateContents(syncKeyBundle, storage_keys) { michael@0: let log = this._log; michael@0: log.info("Updating collection keys..."); michael@0: michael@0: // storage_keys is a WBO, fetched from storage/crypto/keys. michael@0: // Its payload is the default key, and a map of collections to keys. michael@0: // We lazily compute the key objects from the strings we're given. michael@0: michael@0: let payload; michael@0: try { michael@0: payload = storage_keys.decrypt(syncKeyBundle); michael@0: } catch (ex) { michael@0: log.warn("Got exception \"" + ex + "\" decrypting storage keys with sync key."); michael@0: log.info("Aborting updateContents. Rethrowing."); michael@0: throw ex; michael@0: } michael@0: michael@0: let r = this.setContents(payload, storage_keys.modified); michael@0: log.info("Collection keys updated."); michael@0: return r; michael@0: } michael@0: } michael@0: michael@0: this.Collection = function Collection(uri, recordObj, service) { michael@0: if (!service) { michael@0: throw new Error("Collection constructor requires a service."); michael@0: } michael@0: michael@0: Resource.call(this, uri); michael@0: michael@0: // This is a bit hacky, but gets the job done. michael@0: let res = service.resource(uri); michael@0: this.authenticator = res.authenticator; michael@0: michael@0: this._recordObj = recordObj; michael@0: this._service = service; michael@0: michael@0: this._full = false; michael@0: this._ids = null; michael@0: this._limit = 0; michael@0: this._older = 0; michael@0: this._newer = 0; michael@0: this._data = []; michael@0: } michael@0: Collection.prototype = { michael@0: __proto__: Resource.prototype, michael@0: _logName: "Sync.Collection", michael@0: michael@0: _rebuildURL: function Coll__rebuildURL() { michael@0: // XXX should consider what happens if it's not a URL... michael@0: this.uri.QueryInterface(Ci.nsIURL); michael@0: michael@0: let args = []; michael@0: if (this.older) michael@0: args.push('older=' + this.older); michael@0: else if (this.newer) { michael@0: args.push('newer=' + this.newer); michael@0: } michael@0: if (this.full) michael@0: args.push('full=1'); michael@0: if (this.sort) michael@0: args.push('sort=' + this.sort); michael@0: if (this.ids != null) michael@0: args.push("ids=" + this.ids); michael@0: if (this.limit > 0 && this.limit != Infinity) michael@0: args.push("limit=" + this.limit); michael@0: michael@0: this.uri.query = (args.length > 0)? '?' + args.join('&') : ''; michael@0: }, michael@0: michael@0: // get full items michael@0: get full() { return this._full; }, michael@0: set full(value) { michael@0: this._full = value; michael@0: this._rebuildURL(); michael@0: }, michael@0: michael@0: // Apply the action to a certain set of ids michael@0: get ids() this._ids, michael@0: set ids(value) { michael@0: this._ids = value; michael@0: this._rebuildURL(); michael@0: }, michael@0: michael@0: // Limit how many records to get michael@0: get limit() this._limit, michael@0: set limit(value) { michael@0: this._limit = value; michael@0: this._rebuildURL(); michael@0: }, michael@0: michael@0: // get only items modified before some date michael@0: get older() { return this._older; }, michael@0: set older(value) { michael@0: this._older = value; michael@0: this._rebuildURL(); michael@0: }, michael@0: michael@0: // get only items modified since some date michael@0: get newer() { return this._newer; }, michael@0: set newer(value) { michael@0: this._newer = value; michael@0: this._rebuildURL(); michael@0: }, michael@0: michael@0: // get items sorted by some criteria. valid values: michael@0: // oldest (oldest first) michael@0: // newest (newest first) michael@0: // index michael@0: get sort() { return this._sort; }, michael@0: set sort(value) { michael@0: this._sort = value; michael@0: this._rebuildURL(); michael@0: }, michael@0: michael@0: pushData: function Coll_pushData(data) { michael@0: this._data.push(data); michael@0: }, michael@0: michael@0: clearRecords: function Coll_clearRecords() { michael@0: this._data = []; michael@0: }, michael@0: michael@0: set recordHandler(onRecord) { michael@0: // Save this because onProgress is called with this as the ChannelListener michael@0: let coll = this; michael@0: michael@0: // Switch to newline separated records for incremental parsing michael@0: coll.setHeader("Accept", "application/newlines"); michael@0: michael@0: this._onProgress = function() { michael@0: let newline; michael@0: while ((newline = this._data.indexOf("\n")) > 0) { michael@0: // Split the json record from the rest of the data michael@0: let json = this._data.slice(0, newline); michael@0: this._data = this._data.slice(newline + 1); michael@0: michael@0: // Deserialize a record from json and give it to the callback michael@0: let record = new coll._recordObj(); michael@0: record.deserialize(json); michael@0: onRecord(record); michael@0: } michael@0: }; michael@0: }, michael@0: };