Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
465 lines
16 KiB
JavaScript
Executable file
465 lines
16 KiB
JavaScript
Executable file
/* 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;
|
|
})();
|