├── .gitignore ├── README.md ├── entry.js ├── examples ├── bookmarks.js ├── creds.json └── tabs.js ├── index.js ├── package.json ├── sync ├── crypto.js ├── fxaSyncAuth.js ├── fxaUser.js ├── index.js ├── request.js ├── syncAuth.js └── syncClient.js └── test ├── creds.json-copy ├── crypto.js ├── manual.js └── sync-client.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test/creds.json 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-fx-sync 2 | 3 | Create a new Firefox Account in Firefox 29 or newer and you'll be able to use this module to download your Sync data. Fancy! 4 | 5 | ## Install 6 | 7 | npm install fx-sync 8 | 9 | ## Example 10 | 11 | ``` 12 | var FxSync = require('fx-sync'); 13 | 14 | var sync = new FxSync({ email: 'me@example.com', password: 'hunter2' }); 15 | 16 | // Download and print my super useful bookmarks 17 | 18 | sync.fetch('bookmarks') 19 | .then(function(results) { 20 | results 21 | .filter(filterBookmark) 22 | .map(mapBookmark) 23 | .forEach(renderBookmark); 24 | }) 25 | .done(); 26 | 27 | function filterBookmark(bookmark) { 28 | return bookmark.type === 'bookmark'; 29 | } 30 | 31 | function mapBookmark(bookmark) { 32 | return { 33 | title: bookmark.title, 34 | url: bookmark.bmkUri, 35 | description: bookmark.description || '', 36 | tags: bookmark.tags 37 | }; 38 | } 39 | 40 | function renderBookmark(bookmark) { 41 | console.log(bookmark); 42 | } 43 | ``` 44 | 45 | ## API 46 | 47 | #### `sync = new FxSync({ email: , password: })` 48 | 49 | Creates a new instance. 50 | 51 | #### `sync.fetch(collection, options)` 52 | 53 | E.g. `sync.fetch('tabs').then(function (result) { ... });` 54 | 55 | Fetch sync'ed data from `collection`. Useful `collection`s include: `passwords`, `tabs`, `forms`, `prefs`, `bookmarks`, `addons`, and `history`. For information on `options`, [look here](https://docs.services.mozilla.com/storage/apis-1.5.html#individual-collection-interaction). 56 | 57 | #### `sync.fetchIDs(collection, options)` 58 | 59 | E.g. `sync.fetchIDs('history', { limit: 50 }).then(function (result) { ... });` 60 | 61 | Fetch the IDs of objects in `collection`. You can use this to build more complicated queries without downloading the full contents of each object in the query. For information on `options`, [look here](https://docs.services.mozilla.com/storage/apis-1.5.html#individual-collection-interaction). 62 | 63 | 64 | ## License 65 | 66 | Apache License 2.0 67 | -------------------------------------------------------------------------------- /entry.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaach/node-fx-sync/4e9131369d9422fe6bad86f5ab211ba10a536831/entry.js -------------------------------------------------------------------------------- /examples/bookmarks.js: -------------------------------------------------------------------------------- 1 | const FxSync = require('../'); 2 | 3 | var credentials; 4 | 5 | try { 6 | credentials = require('./creds.json'); 7 | } catch (e) { 8 | throw new Error('Create a new Sync account in Firefox >=29 and put your credentials in test/creds.json'); 9 | } 10 | 11 | var sync = new FxSync(credentials); 12 | 13 | sync.fetch('bookmarks') 14 | .then(function(results) { 15 | results 16 | .filter(filterBookmark) 17 | .map(mapBookmark) 18 | .forEach(renderBookmark); 19 | }) 20 | .done(); 21 | 22 | function filterBookmark(bookmark) { 23 | return bookmark.type === 'bookmark'; 24 | } 25 | 26 | function mapBookmark(bookmark) { 27 | return { 28 | title: bookmark.title, 29 | url: bookmark.bmkUri, 30 | description: bookmark.description || '', 31 | tags: bookmark.tags 32 | }; 33 | } 34 | 35 | function renderBookmark(bookmark) { 36 | console.log(bookmark); 37 | } 38 | -------------------------------------------------------------------------------- /examples/creds.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "email@example.com", 3 | "password": "mypassword" 4 | } 5 | -------------------------------------------------------------------------------- /examples/tabs.js: -------------------------------------------------------------------------------- 1 | const FxSync = require('../'); 2 | 3 | var credentials; 4 | 5 | try { 6 | credentials = require('./creds.json'); 7 | } catch (e) { 8 | throw new Error('Create a new Sync account in Firefox >=29 and put your credentials in test/creds.json'); 9 | } 10 | 11 | var sync = new FxSync(credentials); 12 | 13 | sync.fetch('tabs') 14 | .then(function(results) { 15 | results 16 | .map(mapDevice) 17 | .forEach(renderDevice); 18 | }) 19 | .done(); 20 | 21 | 22 | function mapTab(tab) { 23 | return { 24 | title: tab.title, 25 | url: tab.urlHistory[0], 26 | icon: tab.icon, 27 | lastUsed: tab.lastUsed 28 | }; 29 | } 30 | 31 | function mapDevice(device) { 32 | return { 33 | device: device.clientName, 34 | tabs: device.tabs.map(mapTab) 35 | }; 36 | } 37 | 38 | function renderDevice(device) { 39 | console.log(device); 40 | } 41 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./sync')(); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fx-sync", 3 | "version": "0.2.2", 4 | "description": "Interact with Firefox Sync Servers", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node test/crypto.js" 8 | }, 9 | "repository": "https://github.com/zaach/node-fx-sync", 10 | "keywords": [ 11 | "firefox", 12 | "accounts", 13 | "sync" 14 | ], 15 | "author": "Zach Carter", 16 | "license": "APL 2.0", 17 | "dependencies": { 18 | "jwcrypto": "0.4.4", 19 | "request": "2.27.0", 20 | "p-promise": "0.2.5", 21 | "fxa-js-client": "0.1.x", 22 | "hkdf": "0.0.1", 23 | "xmlhttprequest": "git://github.com/zaach/node-XMLHttpRequest.git#onerror", 24 | "hawk": "~1.1.2" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sync/crypto.js: -------------------------------------------------------------------------------- 1 | module.exports = function(P, HKDF, crypto) { 2 | 3 | if (!P) P = require('p-promise'); 4 | if (!HKDF) HKDF = require('hkdf'); 5 | if (!crypto) crypto = require('crypto'); 6 | 7 | 8 | // useful source: https://github.com/mozilla-services/ios-sync-client/blob/master/Sources/NetworkAndStorage/CryptoUtils.m 9 | function decryptWBO(keyBundle, wbo) { 10 | if (!wbo.payload) { 11 | throw new Error("No payload: nothing to decrypt?"); 12 | } 13 | var payload = JSON.parse(wbo.payload); 14 | if (!payload.ciphertext) { 15 | throw new Error("No ciphertext: nothing to decrypt?"); 16 | } 17 | 18 | if (!keyBundle) { 19 | throw new Error("A key bundle must be supplied to decrypt."); 20 | } 21 | 22 | // Authenticate the encrypted blob with the expected HMAC 23 | var computedHMAC = crypto.createHmac('sha256', keyBundle.hmacKey) 24 | .update(payload.ciphertext) 25 | .digest('hex'); 26 | 27 | if (computedHMAC != payload.hmac) { 28 | throw new Error('Incorrect HMAC. Got ' + computedHMAC + '. Expected ' + payload.hmac + '.'); 29 | } 30 | 31 | var iv = Buffer(payload.IV, 'base64').slice(0, 16); 32 | var decipher = crypto.createDecipheriv('aes-256-cbc', keyBundle.encKey, iv) 33 | var plaintext = decipher.update(payload.ciphertext, 'base64', 'utf8') 34 | plaintext += decipher.final('utf8'); 35 | 36 | var result = JSON.parse(plaintext); 37 | 38 | // Verify that the encrypted id matches the requested record's id. 39 | if (result.id !== wbo.id) { 40 | throw new Error("Record id mismatch: " + result.id + " !== " + wbo.id); 41 | } 42 | 43 | return result; 44 | } 45 | 46 | function deriveKeys(syncKey) { 47 | return hkdf(syncKey, "oldsync", undefined, 2 * 32) 48 | .then(function (bundle) { 49 | return { 50 | encKey: bundle.slice(0, 32), 51 | hmacKey: bundle.slice(32, 64) 52 | }; 53 | }); 54 | } 55 | 56 | function decryptCollectionKeys(keyBundle, wbo) { 57 | var decrypted = decryptWBO(keyBundle, wbo); 58 | return { 59 | default: { 60 | encKey: Buffer(decrypted.default[0], 'base64'), 61 | hmacKey: Buffer(decrypted.default[1], 'base64') 62 | } 63 | }; 64 | } 65 | 66 | function kw(name) { 67 | return 'identity.mozilla.com/picl/v1/' + name 68 | } 69 | 70 | function hkdf(km, info, salt, len) { 71 | var d = P.defer() 72 | var df = new HKDF('sha256', salt, km) 73 | df.derive( 74 | kw(info), 75 | len, 76 | function(key) { 77 | d.resolve(key) 78 | } 79 | ) 80 | return d.promise 81 | } 82 | 83 | function computeClientState (bytes) { 84 | var sha = crypto.createHash('sha256'); 85 | return sha.update(bytes).digest().slice(0, 16).toString('hex'); 86 | }; 87 | 88 | return { 89 | decryptWBO: decryptWBO, 90 | deriveKeys: deriveKeys, 91 | decryptCollectionKeys: decryptCollectionKeys, 92 | computeClientState: computeClientState 93 | }; 94 | 95 | }; 96 | -------------------------------------------------------------------------------- /sync/fxaSyncAuth.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function(FxaUser, Crypto) { 3 | if (!FxaUser) FxaUser = require('./fxaUser')(); 4 | if (!Crypto) Crypto = require('./crypto')(); 5 | 6 | function FxaSyncAuth(syncAuth, options) { 7 | this.syncAuth = syncAuth; 8 | this.options = options; 9 | } 10 | 11 | FxaSyncAuth.prototype.auth = function(creds) { 12 | var user = new FxaUser(creds, this.options); 13 | return user.setup() 14 | .then(function() { 15 | this.keys = user.syncKey; 16 | return user.getAssertion(this.options.audience, this.options.duration); 17 | }.bind(this)) 18 | .then(function(assertion) { 19 | var clientState = Crypto.computeClientState(user.syncKey); 20 | return this.syncAuth.auth(assertion, clientState); 21 | }.bind(this)) 22 | .then(function(token) { 23 | return { 24 | token: token, 25 | keys: this.keys, 26 | credentials: { 27 | sessionToken: user.creds.sessionToken, 28 | keyPair: user._keyPair 29 | } 30 | }; 31 | }.bind(this)); 32 | }; 33 | 34 | return FxaSyncAuth; 35 | 36 | }; 37 | -------------------------------------------------------------------------------- /sync/fxaUser.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function(xhr, jwcrypto, P, FxAccountsClient) { 3 | 4 | if (!xhr) xhr = require('xmlhttprequest').XMLHttpRequest; 5 | if (!jwcrypto) { 6 | jwcrypto = require('jwcrypto'); 7 | require("jwcrypto/lib/algs/rs"); 8 | require("jwcrypto/lib/algs/ds"); 9 | } 10 | if (!P) P = require('p-promise'); 11 | if (!FxAccountsClient) FxAccountsClient = require('fxa-js-client'); 12 | 13 | var certDuration = 3600 * 24 * 365; 14 | 15 | /* 16 | * 1. use fxa-client to log in to Fxa with email password 17 | * 2. generate a BrowserID keypair 18 | * 3. send public key to fxa server and get a cert 19 | * 4. generate a BrowserID assertion with the new cert 20 | */ 21 | 22 | function FxUser(creds, options) { 23 | this.email = creds.email; 24 | this.password = creds.password; 25 | this.options = options; 26 | this.client = new FxAccountsClient( 27 | this.options.fxaServerUrl || 'http://127.0.0.1:9000', 28 | { xhr: xhr } 29 | ); 30 | } 31 | 32 | FxUser.prototype.auth = function() { 33 | var self = this; 34 | var creds; 35 | return this.client.signIn( 36 | this.email, 37 | this.password, 38 | { keys: true } 39 | ) 40 | .then(function (creds) { 41 | self.creds = creds; 42 | return self.client.accountKeys(creds.keyFetchToken, creds.unwrapBKey); 43 | }) 44 | .then(function (result) { 45 | self.creds.kB = result.kB; 46 | self.creds.kA = result.kA; 47 | return self; 48 | }); 49 | }; 50 | 51 | FxUser.prototype._exists = function(email) { 52 | var client = new FxAccountsClient(this.options.fxaServerUrl); 53 | return client.accountExists(email); 54 | } 55 | 56 | FxUser.prototype.setup = function() { 57 | var self = this; 58 | var client; 59 | 60 | // initialize the client and obtain keys 61 | return this.auth() 62 | .then( 63 | function () { 64 | return self.client.recoveryEmailStatus(self.creds.sessionToken); 65 | } 66 | ) 67 | .then( 68 | function (status) { 69 | if (status.verified) { 70 | return self.creds; 71 | } else { 72 | // poll for verification or throw? 73 | throw new Error("Unverified account"); 74 | } 75 | } 76 | ) 77 | .then( 78 | function (creds) { 79 | // set the sync key 80 | self.syncKey = Buffer(creds.kB, 'hex'); 81 | var deferred = P.defer(); 82 | // upon allocation of a user, we'll gen a keypair and get a signed cert 83 | jwcrypto.generateKeypair({ algorithm: "DS", keysize: 256 }, function(err, kp) { 84 | if (err) return deferred.reject(err); 85 | 86 | var duration = self.options.certDuration || certDuration; 87 | 88 | self._keyPair = kp; 89 | var expiration = +new Date() + duration; 90 | 91 | self.client.certificateSign(self.creds.sessionToken, kp.publicKey.toSimpleObject(), duration) 92 | .done( 93 | function (cert) { 94 | self._cert = cert.cert; 95 | deferred.resolve(self); 96 | }, 97 | deferred.reject 98 | ); 99 | }); 100 | return deferred.promise; 101 | } 102 | ); 103 | }; 104 | 105 | FxUser.prototype.getCert = function(keyPair) { 106 | var duration = typeof this.options.certDuration !== 'undefined' ? 107 | this.options.certDuration : 108 | 60 * 60 * 1000; 109 | return this.client.certificateSign(this.creds.sessionToken, keyPair.publicKey.toSimpleObject(), duration) 110 | .done( 111 | function (cert) { 112 | self._cert = cert.cert; 113 | deferred.resolve(self); 114 | }, 115 | deferred.reject 116 | ); 117 | }; 118 | 119 | FxUser.prototype.getAssertion = function(audience, duration) { 120 | var deferred = P.defer(); 121 | var self = this; 122 | var expirationDate = +new Date() + (typeof duration !== 'undefined' ? duration : 60 * 60 * 1000); 123 | 124 | jwcrypto.assertion.sign({}, 125 | { 126 | audience: audience, 127 | issuer: this.options.fxaServerUrl, 128 | expiresAt: expirationDate 129 | }, 130 | this._keyPair.secretKey, 131 | function(err, signedObject) { 132 | if (err) return deferred.reject(err); 133 | 134 | var backedAssertion = jwcrypto.cert.bundle([self._cert], signedObject); 135 | deferred.resolve(backedAssertion); 136 | }); 137 | 138 | return deferred.promise; 139 | }; 140 | 141 | return FxUser; 142 | 143 | }; 144 | -------------------------------------------------------------------------------- /sync/index.js: -------------------------------------------------------------------------------- 1 | 2 | // external dependencies 3 | module.exports = function (P, crypto, HKDF, jwcrypto, FxAccountsClient, XHR) { 4 | 5 | const Request = require('./request')(XHR); 6 | const Crypto = require('./crypto')(P, HKDF, crypto); 7 | const SyncAuth = require('./syncAuth')(); 8 | const FxaUser = require('./fxaUser')(P, jwcrypto, FxAccountsClient); 9 | const FxaSyncAuth = require('./fxaSyncAuth')(FxaUser, Crypto); 10 | const SyncClient = require('./syncClient')(Request, Crypto, P); 11 | 12 | const DEFAULTS = { 13 | syncAuthUrl: 'https://token.services.mozilla.com', 14 | fxaServerUrl: 'https://api.accounts.firefox.com/v1', 15 | // certs last a year 16 | duration: 3600 * 24 * 365 17 | }; 18 | 19 | function FxSync(creds, options) { 20 | if (!options) options = {}; 21 | this._creds = creds || {}; 22 | 23 | if (creds.authState) { 24 | this.authState = creds.authState || {}; 25 | this._client = new SyncClient(this.authState); 26 | } 27 | 28 | var authUrl = options.syncAuthUrl || DEFAULTS.syncAuthUrl; 29 | var syncAuth = new SyncAuth(new Request(authUrl)); 30 | 31 | this._authClient = new FxaSyncAuth(syncAuth, { 32 | certDuration: DEFAULTS.duration, 33 | duration: DEFAULTS.duration, 34 | audience: authUrl, 35 | fxaServerUrl: options.fxaServerUrl || DEFAULTS.fxaServerUrl 36 | }); 37 | } 38 | 39 | FxSync.prototype.auth = function(creds) { 40 | return this._auth(creds).then(function() { 41 | return this.authState; 42 | }); 43 | }; 44 | 45 | FxSync.prototype._auth = function(creds) { 46 | if (this._client) return this._client.prepare(); 47 | 48 | return this._authClient.auth(creds || this._creds) 49 | // save credentials 50 | .then(function(authState) { 51 | this.authState = authState; 52 | this._client = new SyncClient(this.authState); 53 | return this._client.prepare(); 54 | }.bind(this)); 55 | }; 56 | 57 | FxSync.prototype.fetchIDs = function(collection, options) { 58 | return this._auth().then(function() { 59 | return this._client.fetchCollection(collection, options); 60 | }.bind(this)); 61 | }; 62 | 63 | FxSync.prototype.fetch = function(collection, options) { 64 | if (!options) options = {}; 65 | options.full = true; 66 | 67 | return this._auth().then(function() { 68 | return this._client.fetchCollection(collection, options); 69 | }.bind(this)); 70 | }; 71 | 72 | return FxSync; 73 | 74 | }; 75 | -------------------------------------------------------------------------------- /sync/request.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function (XHR, hawkClient, P) { 3 | if (!P) P = require("p-promise"); 4 | if (!XHR) XHR = require("xmlhttprequest").XMLHttpRequest; 5 | if (!hawkClient) hawkClient = require("hawk").client; 6 | 7 | function Request(baseUrl, options) { 8 | this.baseUrl = baseUrl; 9 | this.credentials = options && options.credentials; 10 | } 11 | 12 | Request.prototype.get = function(path, options) { 13 | if (!options) options = {}; 14 | options.method = 'GET'; 15 | return this.request(path, options); 16 | }; 17 | 18 | Request.prototype.post = function(path, payload, options) { 19 | if (!options) options = {}; 20 | options.method = 'POST'; 21 | options.json = payload; 22 | return this.request(path, options); 23 | }; 24 | 25 | Request.prototype.put = function(path, payload, options) { 26 | if (!options) options = {}; 27 | options.method = 'PUT'; 28 | options.json = payload; 29 | return this.request(path, options); 30 | }; 31 | 32 | Request.prototype.request = function request(path, options) { 33 | var deferred = P.defer(); 34 | var xhr = new XHR(); 35 | var uri = this.baseUrl + path; 36 | var credentials = options.credentials || this.credentials; 37 | var payload; 38 | 39 | if (options.json) { 40 | payload = JSON.stringify(options.json); 41 | } 42 | 43 | xhr.open(options.method, uri); 44 | xhr.onerror = function onerror() { 45 | deferred.reject(xhr.responseText); 46 | }; 47 | xhr.onload = function onload() { 48 | var result; 49 | if (xhr.responseText === 'Unauthorized') return deferred.reject(xhr.responseText); 50 | try { 51 | result = JSON.parse(xhr.responseText); 52 | } catch (e) { 53 | return deferred.reject(xhr.responseText); 54 | } 55 | if (result.error || xhr.status >= 400) { 56 | return deferred.reject(result); 57 | } 58 | deferred.resolve(result); 59 | }; 60 | 61 | 62 | // calculate Hawk header if credentials are supplied 63 | if (credentials) { 64 | var authHeader = hawkClient.header(uri, options.method, { 65 | credentials: credentials, 66 | payload: payload, 67 | contentType: "application/json" 68 | }); 69 | xhr.setRequestHeader("authorization", authHeader.field); 70 | } 71 | 72 | for (var header in options.headers) { 73 | xhr.setRequestHeader(header, options.headers[header]); 74 | } 75 | 76 | xhr.setRequestHeader("Content-Type", "application/json"); 77 | 78 | xhr.send(payload); 79 | 80 | return deferred.promise; 81 | }; 82 | 83 | return Request; 84 | 85 | } 86 | -------------------------------------------------------------------------------- /sync/syncAuth.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function(Request) { 3 | if (!Request) Request = require('./request')(); 4 | 5 | /* Sync Auth 6 | * 7 | * Uses the auth flow described here: 8 | * https://docs.services.mozilla.com/token/user-flow.html 9 | */ 10 | function SyncAuth(tokenServerClient) { 11 | if (typeof tokenServerClient === 'string') { 12 | tokenServerClient = new Request(tokenServerClient); 13 | } 14 | this.tokenServerClient = tokenServerClient; 15 | } 16 | 17 | /* Auth 18 | * 19 | * @param assertion Serialized BrowserID assertion 20 | * @param clientState hex(first16Bytes(sha256(kBbytes))) 21 | * 22 | * @return Promise result resolves to: 23 | * { 24 | * key: sync 1.5 Hawk key 25 | * id: sync 1.5 Hawk id 26 | * api_endpoint: sync 1.5 storage server uri 27 | * } 28 | */ 29 | 30 | SyncAuth.prototype.auth = function(assertion, clientState) { 31 | return this.tokenServerClient.get('/1.0/sync/1.5', { 32 | headers: { 33 | Authorization: "BrowserID " + assertion, 34 | 'X-Client-State': clientState, 35 | Accept: "application/json" 36 | }, 37 | json: true 38 | }) 39 | .then(function(result) { 40 | this.token = result; 41 | return result; 42 | }.bind(this)); 43 | }; 44 | 45 | return SyncAuth; 46 | 47 | }; 48 | 49 | -------------------------------------------------------------------------------- /sync/syncClient.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function(Request, Crypto, P) { 3 | 4 | if (!Request) Request = require('./request')(); 5 | if (!Crypto) Crypto = require('./crypto')(); 6 | if (!P) P = require('p-promise'); 7 | 8 | /* Sync client 9 | * Uses the auth flow described here: 10 | * https://docs.services.mozilla.com/token/user-flow.html 11 | * 12 | * to obtain a client that can speak: 13 | * https://docs.services.mozilla.com/storage/apis-1.1.html 14 | */ 15 | 16 | function SyncClient(creds) { 17 | this.client = new Request(creds.token.api_endpoint, { 18 | credentials: { 19 | id: creds.token.id, 20 | key: creds.token.key, 21 | algorithm: creds.token.hashalg 22 | } 23 | }); 24 | this.syncKey = creds.keys; 25 | this.keyBundle = creds.keyBundle; 26 | } 27 | 28 | SyncClient.prototype.prepare = function(syncKey) { 29 | return this._deriveKeys(syncKey) 30 | .then(function () { 31 | return this._fetchCollectionKeys(); 32 | }.bind(this)); 33 | }; 34 | 35 | SyncClient.prototype._deriveKeys = function(syncKey) { 36 | if (!syncKey && this.keyBundle) return P(this.keyBundle); 37 | 38 | return Crypto.deriveKeys(syncKey || this.syncKey) 39 | .then(function (bundle) { 40 | return this.keyBundle = bundle; 41 | }.bind(this)); 42 | }; 43 | 44 | SyncClient.prototype._fetchCollectionKeys = function(keyBundle) { 45 | if (!keyBundle && this.collectionKeys) return P(this.collectionKeys); 46 | 47 | return this.client.get('/storage/crypto/keys') 48 | .then(function (wbo) { 49 | return Crypto.decryptCollectionKeys(keyBundle || this.keyBundle, wbo); 50 | }.bind(this), 51 | function(err) { 52 | throw new Error("No collection keys found. Have you set up Sync in your browser?"); 53 | }) 54 | .then(function (collectionKeys) { 55 | return this.collectionKeys = collectionKeys; 56 | }.bind(this)); 57 | }; 58 | 59 | SyncClient.prototype._collectionKey = function (collection) { 60 | return this.collectionKeys[collection] || this.collectionKeys.default; 61 | }; 62 | 63 | SyncClient.prototype.info = function(collection) { 64 | return this.client.get('/info/collections'); 65 | }; 66 | 67 | var VALID_OPTIONS = ['ids', 'newer', 'full', 'limit', 'offset', 'sort']; 68 | 69 | function options2query(options) { 70 | return Object.keys(options) 71 | .filter(function (val) { 72 | return VALID_OPTIONS.indexOf(val) !== -1; 73 | }).map(function (val) { 74 | return val + '=' + encodeURIComponent(serialize(options[val])); 75 | }).join('&'); 76 | } 77 | 78 | function serialize (val) { 79 | return Array.isArray(val) ? val.join(',') : val; 80 | } 81 | 82 | SyncClient.prototype.fetchCollection = function(collection, options) { 83 | var query = options ? '?' + options2query(options) : ''; 84 | var full = options && options.full; 85 | 86 | return this.client.get('/storage/' + collection + query) 87 | .then(function (objects) { 88 | 89 | return full ? 90 | objects.map(function (wbo) { 91 | return Crypto.decryptWBO(this._collectionKey(collection), wbo); 92 | }.bind(this)) : 93 | objects; 94 | }.bind(this)); 95 | }; 96 | 97 | return SyncClient; 98 | 99 | }; 100 | -------------------------------------------------------------------------------- /test/creds.json-copy: -------------------------------------------------------------------------------- 1 | { 2 | "email": "email@example.com", 3 | "password": "mypassword" 4 | } 5 | -------------------------------------------------------------------------------- /test/crypto.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto'); 2 | var assert = require('assert'); 3 | var SyncClient = require('../sync/syncClient')(); 4 | var Crypto = require('../sync/crypto')(); 5 | 6 | var key, iv, payload; 7 | 8 | function encrypt(plaintext, key, iv) { 9 | iv = iv.slice(0, 16); 10 | var cipher = crypto.createCipheriv('aes-256-cbc', key, iv); 11 | cipher.setAutoPadding(true); 12 | var ciphertext = cipher.update(plaintext, 'utf8', 'base64'); 13 | ciphertext += cipher.final('base64'); 14 | 15 | console.log('ciphertext', ciphertext, ciphertext.length); 16 | 17 | return ciphertext; 18 | } 19 | 20 | function decrypt(ciphertext, key, iv) { 21 | iv = iv.slice(0, 16); 22 | console.log('iv', iv, iv.length); 23 | 24 | var decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) 25 | decipher.setAutoPadding(true); 26 | 27 | var plaintext = decipher.update(ciphertext, 'base64', 'utf8') 28 | plaintext += decipher.final('utf8'); 29 | 30 | console.log('plaintext', plaintext); 31 | return plaintext; 32 | } 33 | 34 | 35 | // test basic crypto 36 | key = Buffer("0123456789abcdef0123456789abcdef", "utf8"); 37 | iv = Buffer("0123456789abcdef", "utf8"); 38 | payload = "ThisStringIsExactlyThirtyTwoByte"; 39 | assert.equal(payload, decrypt(encrypt(payload, key, iv), key, iv)); 40 | 41 | expectedCiphertext = Buffer("26fc3a9c95765336123dedcbebcc0c3f652cc4473c6c6f0dfe27c1d4cf04c3ae32bea9f6e1940a15f446f4cbf516141f", "hex"); 42 | 43 | assert.equal(expectedCiphertext.length, 48); 44 | assert.equal(encrypt("ThisStringIsExactlyThirtyTwoByte", key, iv), "Jvw6nJV2UzYSPe3L68wMP2UsxEc8bG8N/ifB1M8Ew64yvqn24ZQKFfRG9Mv1FhQf"); 45 | 46 | 47 | // test key derivation 48 | var keyBundle = { 49 | encKey: Buffer("M2WxH2kvEDlRW3kgPV9KMbI047UPzdVj9X2WSNcaqME=", "base64"), 50 | hmacKey: Buffer("Cxbz9pbZ6RMkyeUXu9Uo4ZTebzp34yjY38/Z14ORSaM=", "base64") 51 | }; 52 | 53 | var derivedKeys = Crypto.deriveKeys(Buffer("a5653a34302125fd0a72619dbcc2cfada1b51d597c9d47995ed127daffcbf6a3", "hex")) 54 | .then(function (bundle) { 55 | assert.equal(bundle.encKey, keyBundle.encKey); 56 | assert.equal(bundle.hmackey, keyBundle.hmackey); 57 | }); 58 | 59 | // test wbo decrypt 60 | var wbo = { payload: '{"ciphertext":"PoI050UwrZvi0o4d/A5ceRQoWfangl8Z3xX81hnkun/6WmHpqx1/bos5LI12OYBfd1FNecjF21bZ8q5D/LB0gKNtpUmAgDcxwe6cyc2BLuqWdQuh/FzRzOt/HVayKFckJ0nrH10zRaR1QhZZRCyKMVjsbdWkUip4NQ/spXdiHc5hgj51oMRujvrJX6YK1bejgvIHx85fvLmt5lKiIAuRhw==","IV":"CIV2kzCWf/aMc5ACcozreQ==","hmac":"c2cca14183dfbd2f345cde850e5450ca4921486f042374de93ce36da3939a73f"}', 61 | id: 'keys', 62 | modified: 1383878611.77 }; 63 | 64 | var result = Crypto.decryptWBO(keyBundle, wbo); 65 | assert.ok(result.default); 66 | 67 | // client state 68 | 69 | var kb = '6b813696a1f83a87e41506b7f33b991b985f3d6e0c934f867544e711882c179c'; 70 | var state = '630b070cdd4369880a82762436c5399d'; 71 | 72 | assert.equal(Crypto.computeClientState(Buffer(kb, 'hex')), state); 73 | -------------------------------------------------------------------------------- /test/manual.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const Sync = require('../sync')(); 3 | 4 | var creds; 5 | 6 | try { 7 | creds = require('./creds.json'); 8 | } catch (e) { 9 | throw new Error('Create a new Sync account in Firefox >=29 and put your credentials in test/creds.json'); 10 | } 11 | 12 | var sync = new Sync(creds); 13 | 14 | sync.fetch('tabs') 15 | .then(function(results) { 16 | console.log('tabs: ', results); 17 | assert.ok(results); 18 | }) 19 | .then(function() { 20 | return sync.fetch('bookmarks'); 21 | }) 22 | .then(function(results) { 23 | console.log('bookmarks: ', results); 24 | assert.ok(results); 25 | }) 26 | .then(function() { 27 | syncToo = new Sync({ authState: sync.authState }); 28 | return syncToo.fetch('bookmarks'); 29 | }) 30 | .then(function(results) { 31 | console.log('bookmarks too: ', results); 32 | assert.ok(results); 33 | }) 34 | .done(); 35 | -------------------------------------------------------------------------------- /test/sync-client.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const assert = require('assert'); 3 | 4 | const Request = require('../sync/request')(); 5 | const SyncAuth = require('../sync/syncAuth')(); 6 | const FxaSyncAuth = require('../sync/fxaSyncAuth')(); 7 | const SyncClient = require('../sync/syncClient')(); 8 | 9 | const duration = 3600 * 24 * 365; 10 | const syncAuthUrl = 'https://token.services.mozilla.com'; 11 | const fxaServerUrl = 'https://api.accounts.firefox.com/v1'; 12 | 13 | var creds; 14 | 15 | try { 16 | creds = require('./creds.json'); 17 | } catch (e) { 18 | throw new Error('Create a new Sync account in Firefox >=29 and put your credentials in test/creds.json'); 19 | } 20 | 21 | const email = creds.email; 22 | const password = creds.password; 23 | 24 | var syncAuth = new SyncAuth(syncAuthUrl); 25 | var auth = new FxaSyncAuth(syncAuth, { 26 | certDuration: duration, 27 | duration: duration, 28 | audience: syncAuthUrl, 29 | fxaServerUrl: fxaServerUrl 30 | }); 31 | 32 | var syncClient; 33 | 34 | auth.auth(creds) 35 | .then(function(creds) { 36 | console.log('creds??', creds); 37 | 38 | syncClient = new SyncClient(creds); 39 | }) 40 | .then(function() { 41 | return syncClient.info(); 42 | }) 43 | .then(function(info) { 44 | console.log('info', info); 45 | }) 46 | .then(function() { 47 | return syncClient.fetchCollection('tabs'); 48 | }) 49 | .then(function(info) { 50 | console.log('tabs', info); 51 | }) 52 | .then(function() { 53 | return syncClient.fetchCollection('clients'); 54 | }) 55 | .then(function(info) { 56 | console.log('clients', info); 57 | }) 58 | .then(function() { 59 | return syncClient.fetchCollection('meta'); 60 | }) 61 | .then(function(info) { 62 | console.log('meta', info); 63 | }) 64 | .then(function() { 65 | return syncClient.fetchCollection('crypto'); 66 | }) 67 | .then(function(info) { 68 | console.log('crypto', info); 69 | }) 70 | .then(function() { 71 | return syncClient.fetchCollection('keys'); 72 | }) 73 | .then(function(info) { 74 | console.log('keys', info); 75 | return syncClient.prepare(); 76 | }) 77 | .then(function(data) { 78 | console.log('result ', data); 79 | assert.ok(syncClient.keyBundle.encKey, 'has encryption key'); 80 | assert.ok(syncClient.keyBundle.hmacKey, 'has hmac key'); 81 | return syncClient.fetchCollection('tabs', { full: true }); 82 | }) 83 | .done(function(results) { 84 | console.log('bookmarks: ', results[0]); 85 | }, 86 | function (err) { 87 | console.error('error: ', err, err.stack); 88 | }); 89 | --------------------------------------------------------------------------------