Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
465 lines
16 KiB
JavaScript
465 lines
16 KiB
JavaScript
/* 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/. */
|
|
|
|
/**
|
|
* SyncEngine
|
|
*
|
|
* The SyncEngine constructor takes care of:
|
|
* - Setting up the kinto.js client.
|
|
* - Configuring kinto.js to connect to the remote Syncto server (which in turn
|
|
* proxies all requests to the remote Firefox Sync server).
|
|
* - Creating kinto.js collections for the 'meta' and 'crypto' collections, with
|
|
* the appropriate IdSchemas (the 'meta' collection only contains one record,
|
|
* called 'global', and the 'crypto' collection only contains one record,
|
|
* called 'keys').
|
|
* - Setting up the FxSyncWebCrypto instance to be used for cryptographically
|
|
* encoding / decoding records, before uploading / after downloading them.
|
|
* - Registering the DataAdapters to be used on each collection (see below).
|
|
*
|
|
* The `syncNow` method takes care of:
|
|
* - If called for the first time, or if this failed the previous time:
|
|
* - Retrieving 'meta/global' to check the storageVersion.
|
|
* - Retrieving and decrypting 'crypto/keys' to obtain the bulk key bundle.
|
|
* - Making sure a kinto.js collection exists for each collection that needs
|
|
* syncing.
|
|
* - These collections are set up with the correct IdSchema (typically
|
|
* allowing only 9-character URL-safe Base64 strings, although for some
|
|
* collections other strings are allowed, e.g. bookmarks also has id's like
|
|
* 'menu' and 'toolbar').
|
|
* - Adding a RemoteTransformer to each of these collections, so that outgoing
|
|
* and incoming data is passed through FxSyncWebCrypto for its en-/decryption.
|
|
* - For each collection to be synced, the following steps occur:
|
|
* - Download the latest remote data into the local decrypted copy, managed by
|
|
* kinto.js.
|
|
* - For each incoming conflict reported by kinto.js (if any), call the
|
|
* `handleConflict` method on the DataAdapter registered for this
|
|
* collection.
|
|
* - When kinto.js has obtained a full decrypted copy of the remote data, call
|
|
* the `update` method on the DataAdapter, passing it an interface to the
|
|
* Kinto collection in question.
|
|
* - When this method returns its promise, sync the Kinto collection a second
|
|
* time, to make sure any changes made there by the DataAdapter are synced
|
|
* up to the remote Firefox Sync server (again, through the remote Syncto
|
|
* proxy server). The RemoteTransformer on the Kinto collection will take
|
|
* care of encrypting the records before they get uploaded.
|
|
* - Call the `handleConflict` method on the DataAdapter again for any
|
|
* conflicts kinto.js may report during this second sync process.
|
|
*
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
/* global
|
|
crypto,
|
|
FxSyncWebCrypto,
|
|
Kinto
|
|
*/
|
|
|
|
/* exported
|
|
SyncEngine
|
|
*/
|
|
|
|
var SyncEngine = (function() {
|
|
const HTTP_TIMEOUT_SECONDS = 180;
|
|
|
|
var createFxSyncIdSchema = () => {
|
|
return {
|
|
generate: function() {
|
|
var bytes = new Uint8Array(9);
|
|
crypto.getRandomValues(bytes);
|
|
var binStr = '';
|
|
for (var i = 0; i < 9; i++) {
|
|
binStr += String.fromCharCode(bytes[i]);
|
|
}
|
|
// See https://docs.services.mozilla.com/storage/apis-1.5.html
|
|
return window.btoa(binStr).replace('+', '-').replace('/', '_');
|
|
},
|
|
validate: function(id) {
|
|
// FxSync id's "should" be 12 ASCII characters, representing 9 bytes of
|
|
// data in modified Base64 for URL variants, where the '+' and '/'
|
|
// characters of standard Base64 are respectively replaced by '-' and
|
|
// '_'. See https://docs.services.mozilla.com/storage/apis-1.5.html
|
|
// But in practice, they could be any string, see bug 1209906.
|
|
return (typeof id === 'string');
|
|
}
|
|
};
|
|
};
|
|
|
|
var createControlCollectionIdSchema = (keyName) => {
|
|
return {
|
|
generate: function() {
|
|
return keyName;
|
|
},
|
|
validate: function(id) {
|
|
return (id === keyName);
|
|
}
|
|
};
|
|
};
|
|
|
|
var createWebCryptoTransformer = (collectionName, fswc) => {
|
|
if (!fswc.bulkKeyBundle) {
|
|
throw new Error(
|
|
'Attempt to register Transformer with no bulk key bundle!');
|
|
}
|
|
return {
|
|
encode: function(record) {
|
|
return fswc.encrypt(record.payload, collectionName).then(
|
|
payloadEnc => {
|
|
return {
|
|
id: record.id,
|
|
payload: JSON.stringify(payloadEnc)
|
|
};
|
|
});
|
|
},
|
|
decode: function(record) {
|
|
// Allowing JSON.parse errors to bubble up to the errors list in the
|
|
// syncResults:
|
|
return fswc.decrypt(JSON.parse(record.payload), collectionName).
|
|
then(payloadDec => {
|
|
return {
|
|
id: record.id,
|
|
last_modified: record.last_modified,
|
|
payload: payloadDec
|
|
};
|
|
});
|
|
}
|
|
};
|
|
};
|
|
|
|
const generateXClientState = (kB) => {
|
|
var kBarray = [];
|
|
for(var i = 0; i < kB.length; i += 2){
|
|
kBarray.push(parseInt(kB.substring(i, i + 2), 16));
|
|
}
|
|
return window.crypto.subtle.digest({name: 'SHA-256'},
|
|
new Uint8Array(kBarray))
|
|
.then(hash => {
|
|
var bytes = new Uint8Array(hash).slice(0, 16);
|
|
var array = [];
|
|
bytes.forEach(byte => {
|
|
array.push((byte < 16 ? '0' : '') + byte.toString(16).toLowerCase());
|
|
});
|
|
return array.join('');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* SyncEngine - Constructor.
|
|
* @param {Object} options Should contain the following fields:
|
|
* * URL - e.g. 'http://localhost:8000/v1/'
|
|
* * assertion - a BrowserID assertion (Base64)
|
|
* * kB - kB key from token server (Base64)
|
|
* * adapters - object whose keys are collection
|
|
* names, and whose values are
|
|
* DataAdapter instances.
|
|
* @returns {SyncEngine}
|
|
*/
|
|
var SyncEngine = function(options) {
|
|
if (typeof options !== 'object') {
|
|
throw new Error('options should be an Object');
|
|
}
|
|
['URL', 'assertion', 'kB'].forEach(field => {
|
|
if (typeof options[field] !== 'string') {
|
|
throw new Error(`options.${field} should be a String`);
|
|
}
|
|
});
|
|
if (typeof options.adapters !== 'object') {
|
|
throw new Error('options.adapters should be an Object');
|
|
}
|
|
for (var collectionName in options.adapters) {
|
|
if (typeof options.adapters[collectionName] !== 'object') {
|
|
throw new Error(`options.adapters.${collectionName} should be an Object\
|
|
`);
|
|
}
|
|
['update', 'handleConflict'].forEach(methodName => {
|
|
if (typeof options.adapters[collectionName][methodName] !==
|
|
'function') {
|
|
throw new Error(`options.adapters.${collectionName}.${methodName} sho\
|
|
uld be a Function`);
|
|
}
|
|
});
|
|
}
|
|
|
|
['kB', 'assertion', 'URL', 'adapters'].forEach(field => {
|
|
this[`_${field}`] = options[field];
|
|
});
|
|
|
|
this._collections = {};
|
|
this._controlCollections = {};
|
|
this._fswc = new FxSyncWebCrypto();
|
|
this._engines = null;
|
|
this._kinto = null;
|
|
this._xClientState = null;
|
|
this._haveUnsyncedConflicts = {};
|
|
this._ready = false;
|
|
};
|
|
|
|
function promiseWaterfall(self, tasks, executor) {
|
|
return tasks.reduce((reduced, current) => {
|
|
return reduced.then(() => {
|
|
return executor.apply(self, current);
|
|
});
|
|
}, Promise.resolve());
|
|
}
|
|
|
|
SyncEngine.prototype = {
|
|
_reset: function(collectionName, userid) {
|
|
var collectionsToReset = ['meta', 'crypto'];
|
|
if (collectionName) {
|
|
collectionsToReset.push(collectionName);
|
|
}
|
|
return Promise.all(collectionsToReset.map(collectionName => {
|
|
var coll = this._getCollection(collectionName);
|
|
return coll.clear().then(() => {
|
|
if (this._adapters[collectionName]) {
|
|
return this._adapters[collectionName].reset({ userid });
|
|
}
|
|
});
|
|
}));
|
|
},
|
|
|
|
_createKinto: function(kintoCredentials) {
|
|
var kinto = new Kinto({
|
|
bucket: kintoCredentials.xClientState,
|
|
remote: kintoCredentials.URL,
|
|
timeout: HTTP_TIMEOUT_SECONDS * 1000,
|
|
headers: {
|
|
'Authorization': 'BrowserID ' + kintoCredentials.assertion
|
|
}
|
|
});
|
|
var addControlCollection = (collectionName, keyName) => {
|
|
var idSchema = createControlCollectionIdSchema(keyName);
|
|
this._controlCollections[collectionName] =
|
|
kinto.collection(collectionName, { idSchema });
|
|
};
|
|
addControlCollection('meta', 'global');
|
|
addControlCollection('crypto', 'keys');
|
|
return kinto;
|
|
},
|
|
|
|
_getCollection: function(collectionName) {
|
|
if (['meta', 'crypto'].indexOf(collectionName) === -1) {
|
|
return this._collections[collectionName];
|
|
}
|
|
return this._controlCollections[collectionName];
|
|
},
|
|
|
|
_getItem: function(collectionName, itemName, syncIfNeeded) {
|
|
return this._getCollection(collectionName).get(itemName).catch(err => {
|
|
if (syncIfNeeded) {
|
|
return this._syncCollection(collectionName).then(() => {
|
|
return this._getItem(collectionName, itemName, false);
|
|
});
|
|
}
|
|
if (err.message === 'Record with id=global not found.') {
|
|
throw new SyncEngine.InvalidAccountError();
|
|
}
|
|
throw err;
|
|
});
|
|
},
|
|
|
|
_resolveConflicts: function(collectionName, conflicts) {
|
|
return Promise.all(conflicts.map(conflict => {
|
|
return this._adapters[collectionName].handleConflict(conflict)
|
|
.then(resolution =>
|
|
this._collections[collectionName].resolve(conflict, resolution))
|
|
.then(() => this._haveUnsyncedConflicts[collectionName] = true);
|
|
}));
|
|
},
|
|
|
|
_syncCollection: function(collectionName, collectionOptions) {
|
|
var collection = this._getCollection(collectionName);
|
|
// Let synchronization strategy default to 'manual', see
|
|
// http://kintojs.readthedocs.org \
|
|
// /en/latest/api/#fetching-and-publishing-changes
|
|
|
|
var userid = collectionOptions && collectionOptions.userid;
|
|
return collection.sync({
|
|
fetchLimit: (collectionOptions && collectionOptions.limit) || undefined
|
|
}).then(syncResults => {
|
|
if (syncResults && syncResults.errors && syncResults.errors.length) {
|
|
var error = new Error('Errors in SyncResults');
|
|
error.data = syncResults;
|
|
throw error;
|
|
}
|
|
if (syncResults.conflicts.length) {
|
|
return this._resolveConflicts(collectionName, syncResults.conflicts);
|
|
}
|
|
}).catch(err => {
|
|
if (err instanceof Error && typeof err.response === 'object') {
|
|
if (err.response.status === 401) {
|
|
throw new SyncEngine.AuthError(err.message);
|
|
}
|
|
throw new SyncEngine.TryLaterError(err.message);
|
|
}
|
|
if (err.message === `HTTP 0; TypeError: NetworkError when attempting to\
|
|
fetch resource.`) {
|
|
throw new SyncEngine.TryLaterError('Syncto server unreachable',
|
|
this._kinto && this._kinto._options &&
|
|
this._kinto._options.remote);
|
|
}
|
|
return this._reset(collectionName, userid).then(
|
|
() => {
|
|
throw new SyncEngine.UnrecoverableError(err.message);
|
|
});
|
|
});
|
|
},
|
|
|
|
_checkMetaGlobal: function(metaGlobal) {
|
|
var payloadObj;
|
|
try {
|
|
payloadObj = JSON.parse(metaGlobal.data.payload);
|
|
} catch(e) {
|
|
return false;
|
|
}
|
|
this._engines = payloadObj.engines;
|
|
|
|
return (typeof payloadObj === 'object' &&
|
|
payloadObj.storageVersion === 5);
|
|
},
|
|
|
|
_initFxSyncWebCrypto: function(cryptoKeys) {
|
|
this._fswc = new FxSyncWebCrypto();
|
|
return this._fswc.setKeys(this._kB, cryptoKeys).then(() => {
|
|
this._createCollections();
|
|
this._ready = true;
|
|
}, err => {
|
|
if (err === 'SyncKeys hmac could not be verified with current main ' +
|
|
'key') {
|
|
this._reset().then(() => {
|
|
throw new SyncEngine.UnrecoverableError(err);
|
|
});
|
|
}
|
|
throw err;
|
|
});
|
|
},
|
|
|
|
_ensureReady: function() {
|
|
if (this._ready) {
|
|
return Promise.resolve();
|
|
}
|
|
return generateXClientState(this._kB).then(xClientState => {
|
|
this._kinto = this._createKinto({
|
|
URL: this._URL,
|
|
assertion: this._assertion,
|
|
xClientState
|
|
});
|
|
this._xClientState = xClientState;
|
|
}).then(() => {
|
|
return this._syncCollection('meta');
|
|
}).then(() => {
|
|
return this._getItem('meta', 'global');
|
|
}).then(metaGlobal => {
|
|
if (!this._checkMetaGlobal(metaGlobal)) {
|
|
throw new SyncEngine.UnrecoverableError(`Incompatible storage version\
|
|
or storage version not recognized.`);
|
|
}
|
|
});
|
|
},
|
|
|
|
_getBulkKeyBundle: function() {
|
|
return this._getItem('crypto', 'keys', true /* syncIfNeeded */)
|
|
.then((cryptoKeysRecord) => {
|
|
var cryptoKeys;
|
|
try {
|
|
cryptoKeys = JSON.parse(cryptoKeysRecord.data.payload);
|
|
} catch (e) {
|
|
throw new SyncEngine.UnrecoverableError(`Could not parse crypto/keys \
|
|
payload as JSON`);
|
|
}
|
|
return cryptoKeys;
|
|
}).then((cryptoKeys) => {
|
|
return this._initFxSyncWebCrypto(cryptoKeys);
|
|
});
|
|
},
|
|
|
|
_createCollections: function() {
|
|
for (var collectionName in this._adapters) {
|
|
this._collections[collectionName] = this._kinto.collection(
|
|
collectionName, {
|
|
idSchema: createFxSyncIdSchema(collectionName),
|
|
remoteTransformers: [
|
|
createWebCryptoTransformer(collectionName, this._fswc)
|
|
]
|
|
});
|
|
}
|
|
},
|
|
|
|
_updateCollection: function(collectionName, collectionOptions) {
|
|
if (this._engines && !this._engines[collectionName]) {
|
|
console.warn(`Collection ${collectionName} not present on this FxSync a\
|
|
ccount`);
|
|
return Promise.resolve();
|
|
}
|
|
return this._syncCollection(collectionName, collectionOptions)
|
|
.then(() => {
|
|
return this._adapters[collectionName].update(
|
|
this._collections[collectionName], collectionOptions);
|
|
}).then(changed => {
|
|
if (!changed && !this._haveUnsyncedConflicts[collectionName]) {
|
|
return Promise.resolve();
|
|
}
|
|
return this._syncCollection(collectionName, collectionOptions)
|
|
.then(() => {
|
|
this._haveUnsyncedConflicts[collectionName] = false;
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* syncNow - Syncs collections up and down between device and server.
|
|
* @param {object} collectionOptions The options per collection. Can be:
|
|
* {boolean} readonly (defaults to true)
|
|
* {number} limit
|
|
* @returns {Promise}
|
|
*/
|
|
syncNow: function(collectionOptions) {
|
|
if (typeof collectionOptions !== 'object') {
|
|
return Promise.reject(new Error(
|
|
'collectionOptions should be an object'));
|
|
}
|
|
return this._ensureReady().then(() => {
|
|
if (Object.keys(collectionOptions).length === 0) {
|
|
// This can happen if the system app just wanted to check if an
|
|
// FxSync account exists for the credentials derived from kB.
|
|
return Promise.resolve();
|
|
}
|
|
return this._getBulkKeyBundle().then(() => {
|
|
var tasks = [];
|
|
for (var collectionName in collectionOptions) {
|
|
collectionOptions[collectionName].userid = this._xClientState;
|
|
tasks.push([ collectionName, collectionOptions[collectionName] ]);
|
|
}
|
|
return promiseWaterfall(this, tasks, this._updateCollection);
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
SyncEngine.UnrecoverableError = function() {
|
|
console.error('[SyncEngine Unrecoverable]', arguments);
|
|
this.message = 'unrecoverable';
|
|
};
|
|
SyncEngine.UnrecoverableError.prototype = Object.create(Error.prototype);
|
|
|
|
SyncEngine.TryLaterError = function() {
|
|
console.error('[SyncEngine TryLater]', arguments);
|
|
this.message = 'try later';
|
|
};
|
|
SyncEngine.TryLaterError.prototype = Object.create(Error.prototype);
|
|
|
|
SyncEngine.AuthError = function() {
|
|
console.error('[SyncEngine Auth]', arguments);
|
|
this.message = 'unauthorized';
|
|
};
|
|
SyncEngine.AuthError.prototype = Object.create(Error.prototype);
|
|
|
|
SyncEngine.InvalidAccountError = function() {
|
|
console.error('[SyncEngine InvalidAccount]', arguments);
|
|
this.message = 'invalid account';
|
|
};
|
|
SyncEngine.InvalidAccountError.prototype = Object.create(Error.prototype);
|
|
|
|
return SyncEngine;
|
|
})();
|