From 0e29c3fd94d7e76dab8ceef4fb3e027f96f96e42 Mon Sep 17 00:00:00 2001 From: Ayumi Yu Date: Wed, 28 Dec 2016 15:37:55 -0800 Subject: [PATCH] client cryptoUtil; requestUtil .refreshAWSCredentials --- client/cryptoUtil.js | 77 +++++++++++++++++++++++++ client/requestUtil.js | 112 +++++++++++++++++++++++++++---------- client/sync.js | 75 ++++--------------------- test/client/requestUtil.js | 91 ++++++++++++++++++------------ test/client/testHelper.js | 37 ++---------- test/server/users.js | 12 +++- 6 files changed, 238 insertions(+), 166 deletions(-) create mode 100644 client/cryptoUtil.js diff --git a/client/cryptoUtil.js b/client/cryptoUtil.js new file mode 100644 index 0000000..7f7cdef --- /dev/null +++ b/client/cryptoUtil.js @@ -0,0 +1,77 @@ +'use strict' + +const crypto = require('../lib/crypto') + +/** + * Create a function which will serialize then encrypt a sync record. + * @param {Serializer} serializer + * @param {Uint8Array} secretboxKey + * @param {number} nonceCounter + * @returns {Function} + */ +module.exports.Encrypt = (serializer, secretboxKey, nonceCounter) => { + return (syncRecord) => { + return this.encrypt(serializer, secretboxKey, syncRecord, nonceCounter) + } +} + +/** + * Serialize then encrypt a sync record. + * Note this is the browser JS version, so it uses Uint8Array as the main way to + * express byte arrays. + * @param {Serializer} serializer + * @param {Uint8Array} secretboxKey + * @param {Object} syncRecord + * @param {number} nonceCounter + * @returns {Uint8Array} + */ +module.exports.encrypt = (serializer, secretboxKey, syncRecord, nonceCounter) => { + const bytes = serializer.syncRecordToByteArray(syncRecord) + const nonceRandom = Buffer.from(crypto.randomBytes(20)) + const encrypted = crypto.encrypt(bytes, secretboxKey, nonceCounter, nonceRandom) + return serializer.SecretboxRecordToByteArray({ + encryptedData: encrypted.ciphertext, + nonceCounter, + nonceRandom: encrypted.nonce + }) +} + +/** + * Create a function which will decrypt then deserialize a message. + * @param {Serializer} serializer + * @param {Uint8Array} secretboxKey + * @returns {Function} + */ +module.exports.Decrypt = (serializer, secretboxKey) => { + return (secretboxRecordBytes) => { + return this.decrypt(serializer, secretboxKey, secretboxRecordBytes) + } +} + +/** + * Decrypt then deserialize a message. + * @param {Serializer} serializer + * @param {Uint8Array} secretboxKey + * @param {Uint8Array} secretboxRecordBytes + * @returns {Object} + */ +module.exports.decrypt = (serializer, secretboxKey, secretboxRecordBytes) => { + const secretboxRecord = serializer.byteArrayToSecretboxRecord(secretboxRecordBytes) + const decryptedBytes = crypto.decrypt( + secretboxRecord.encryptedData, + secretboxRecord.nonceRandom, + Buffer.from(secretboxKey) + ) + return serializer.byteArrayToSyncRecord(decryptedBytes) +} + +/** + * Create a function which will sign some bytes. + * @param {Uint8Array} secretKey + * @returns {Function} + */ +module.exports.Sign = (secretKey) => { + return (bytes) => { + return crypto.sign(bytes, secretKey) + } +} diff --git a/client/requestUtil.js b/client/requestUtil.js index 3d0accb..908dd00 100644 --- a/client/requestUtil.js +++ b/client/requestUtil.js @@ -1,27 +1,91 @@ 'use strict' +const NONCE_COUNTER = 0 + const awsSdk = require('aws-sdk') +const cryptoUtil = require('./cryptoUtil') const s3Helper = require('../lib/s3Helper') +const checkFetchStatus = (response) => { + if (response.status >= 200 && response.status < 300) { + return response + } else { + var error = new Error(response.statusText) + error.response = response + throw error + } +} + +const getTime = () => { + return Math.floor(Date.now() / 1000) +} + /** - * @param {Object} serializer - * @param {Uint8Array} credentialsBytes - * @param {string} apiVersion - * @param {string} userId + * @param {{ + * apiVersion: , + * credentialsBytes: , // If missing, will be requested + * keys: {{ // User's encryption keys + * publicKey: , secretKey: , + * fingerprint: , secretboxKey: }}, + * serializer: , + * serverUrl: + * }} opts */ -const RequestUtil = function (serializer, credentialsBytes, apiVersion, userId) { - if (!apiVersion) { throw new Error('Missing apiVersion.') } - this.apiVersion = apiVersion - if (!userId) { throw new Error('Missing userId.') } - this.userId = userId - - this.serializer = serializer - const response = this.parseAWSResponse(credentialsBytes) - this.s3 = response.s3 - this.postData = response.postData - this.expiration = response.expiration - this.bucket = response.bucket - this.region = response.region +const RequestUtil = function (opts = {}) { + if (!opts.apiVersion) { throw new Error('Missing apiVersion.') } + if (!opts.keys) { throw new Error('Missing keys.') } + if (!opts.serializer) { throw new Error('Missing serializer.') } + if (!opts.serverUrl) { throw new Error('Missing serverUrl.') } + this.apiVersion = opts.apiVersion + this.serializer = opts.serializer + this.serverUrl = opts.serverUrl + this.userId = Buffer.from(opts.keys.publicKey).toString('base64') + this.encrypt = cryptoUtil.Encrypt(this.serializer, opts.keys.secretboxKey, NONCE_COUNTER) + this.decrypt = cryptoUtil.Decrypt(this.serializer, opts.keys.secretboxKey) + this.sign = cryptoUtil.Sign(opts.keys.secretKey) + if (opts.credentialsBytes) { + const credentials = this.parseAWSResponse(opts.credentialsBytes) + this.saveAWSCredentials(credentials) + } +} + +/** + * Save parsed AWS credential response to be used with AWS requests. + * @param {{s3: Object, postData: Object, expiration: string, bucket: string, region: string}} + * @return {Promise} After it resolves, the object is ready to make requests. + */ +RequestUtil.prototype.refreshAWSCredentials = function () { + const timestampString = getTime().toString() + const userId = window.encodeURIComponent(this.userId) + const url = `${this.serverUrl}/${userId}/credentials` + const bytes = this.serializer.stringToByteArray(timestampString) + const params = { + method: 'POST', + body: this.sign(bytes) + } + return window.fetch(url, params) + .then((response) => { + if (!response.ok) { + throw new Error(`Credential server response ${response.status}`) + } + return response.arrayBuffer() + }) + .then((buffer) => { + const credentials = this.parseAWSResponse(new Uint8Array(buffer)) + this.saveAWSCredentials(credentials) + }) +} + +/** + * Save parsed AWS credential response to be used with AWS requests. + * @param {{s3: Object, postData: Object, expiration: string, bucket: string, region: string}} + */ +RequestUtil.prototype.saveAWSCredentials = function (parsedResponse) { + this.s3 = parsedResponse.s3 + this.postData = parsedResponse.postData + this.expiration = parsedResponse.expiration + this.bucket = parsedResponse.bucket + this.region = parsedResponse.region this.s3PostEndpoint = `https://${this.bucket}.s3.dualstack.${this.region}.amazonaws.com` } @@ -154,18 +218,4 @@ RequestUtil.prototype.deleteCategory = function (category) { return this.s3DeletePrefix(`${this.apiVersion}/${this.userId}/${category}`) } -function checkFetchStatus (response) { - if (response.status >= 200 && response.status < 300) { - return response - } else { - var error = new Error(response.statusText) - error.response = response - throw error - } -} - -function getTime () { - return Math.floor(Date.now() / 1000) -} - module.exports = RequestUtil diff --git a/client/sync.js b/client/sync.js index 89a6731..c592285 100644 --- a/client/sync.js +++ b/client/sync.js @@ -1,11 +1,11 @@ 'use strict' const initializer = require('./init') +const cryptoUtil = require('./cryptoUtil') const RequestUtil = require('./requestUtil') const messages = require('./constants/messages') const proto = require('./constants/proto') const serializer = require('../lib/serializer') -const crypto = require('../lib/crypto') const conf = require('./config') const ipc = window.chrome.ipcRenderer @@ -47,68 +47,8 @@ const logSync = (message, logLevel = DEBUG) => { } } -/** - * decrypt then deserialize a message. - * @param {Uint8Array} ciphertext - * @returns {Object} - */ -const decrypt = (ciphertext) => { - const d = clientSerializer.byteArrayToSecretboxRecord(ciphertext) - const decrypted = crypto.decrypt(d.encryptedData, - d.nonceRandom, clientKeys.secretboxKey) - if (!decrypted) { - throw new Error('Decryption failed.') - } - return clientSerializer.byteArrayToSyncRecord(decrypted) -} - -/** - * serialize then encrypts a sync record - * @param {Object} message - * @returns {Uint8Array} - */ -const encrypt = (message) => { - const s = clientSerializer.syncRecordToByteArray(message) - const nonceRandom = crypto.randomBytes(20) - const encrypted = crypto.encrypt(s, clientKeys.secretboxKey, - conf.nonceCounter, nonceRandom) - return clientSerializer.SecretboxRecordToByteArray({ - nonceRandom, - counter: conf.counter, - encryptedData: encrypted.ciphertext - }) -} - -/** - * Gets AWS creds. - * @returns {Promise} - */ -const getAWSCredentials = () => { - const serverUrl = config.serverUrl - const now = Math.floor(Date.now() / 1000).toString() - if (clientSerializer === null) { - throw new Error('Serializer not initialized.') - } - const userId = window.encodeURIComponent(clientUserId) - const request = new window.Request(`${serverUrl}/${userId}/credentials`, { - method: 'POST', - body: crypto.sign(clientSerializer.stringToByteArray(now), clientKeys.secretKey) - }) - return window.fetch(request) - .then((response) => { - if (!response.ok) { - throw new Error('Credential server response ' + response.status) - } - return response.arrayBuffer() - }) - .then((buffer) => { - requester = new RequestUtil(clientSerializer, new Uint8Array(buffer), - config.apiVersion, clientUserId) - if (!requester.s3) { - throw new Error('could not initialize AWS SDK') - } - }) -} +const decrypt = cryptoUtil.Decrypt(clientSerializer, clientKeys.secretboxKey) +const encrypt = cryptoUtil.Encrypt(clientSerializer, clientKeys.secretboxKey, conf.nonceCounter) /** * Sets the device ID if one does not yet exist. @@ -196,7 +136,14 @@ Promise.all([serializer.init(''), initializer.init(window.chrome)]).then((values logSync(`initialized userId ${clientUserId}`) }) .then(() => { - return getAWSCredentials() + requester = new RequestUtil({ + apiVersion: config.apiVersion, + credentialsBytes: null, // TODO: Start with previous session's credentials + keys: clientKeys, + serializer: clientSerializer, + syncServerUrl: config.serverUrl + }) + return requester.refreshAWSCredentials() }) .then(() => { logSync('successfully authenticated userId: ' + clientUserId) diff --git a/test/client/requestUtil.js b/test/client/requestUtil.js index 667658d..c79bb7b 100644 --- a/test/client/requestUtil.js +++ b/test/client/requestUtil.js @@ -2,6 +2,7 @@ const test = require('tape') const testHelper = require('../testHelper') const timekeeper = require('timekeeper') const clientTestHelper = require('./testHelper') +const cryptoUtil = require('../../client/cryptoUtil') const Serializer = require('../../lib/serializer') const RequestUtil = require('../../client/requestUtil') const proto = require('../../client/constants/proto') @@ -9,34 +10,31 @@ const proto = require('../../client/constants/proto') test('client RequestUtil', (t) => { t.plan(1) t.test('constructor', (t) => { - t.plan(6) + t.plan(7) Serializer.init().then((serializer) => { clientTestHelper.getSerializedCredentials(serializer).then((data) => { - const keys = data.keys - const args = [ + const args = { + apiVersion: clientTestHelper.CONFIG.apiVersion, + credentialsBytes: data.serializedCredentials, + keys: data.keys, serializer, - data.serializedCredentials, - clientTestHelper.CONFIG.apiVersion, - data.userId - ] + serverUrl: clientTestHelper.CONFIG.serverUrl + } + t.throws(() => { return new RequestUtil() }, 'requires arguments') - t.throws(() => { return new RequestUtil(...args.slice(0, 2)) }, 'requires apiVersion') - t.throws(() => { return new RequestUtil(...args.slice(0, 3)) }, 'requires userId') + const requiredArgs = ['apiVersion', 'keys', 'serializer', 'serverUrl'] + for (let arg of requiredArgs) { + let lessArgs = Object.assign({}, args) + lessArgs[arg] = undefined + t.throws(() => { return new RequestUtil(lessArgs) }, `requires ${arg}`) + } - const requestUtil = new RequestUtil(...args) + const requestUtil = new RequestUtil(args) t.pass('can instantiate requestUtil') t.test('prototype', (t) => { - testPrototype(t, requestUtil, keys) + testPrototype(t, requestUtil, data.keys) }) - - const expiredCredentials = { - aws: clientTestHelper.EXPIRED_CREDENTIALS.aws, - s3Post: clientTestHelper.EXPIRED_CREDENTIALS.s3Post, - bucket: requestUtil.bucket, - region: requestUtil.region - } - testExpiredCredentials(t, expiredCredentials, keys, serializer) }).catch((error) => { t.end(error) }) }) }) @@ -48,9 +46,9 @@ test('client RequestUtil', (t) => { }) const serializer = requestUtil.serializer const decrypt = testHelper.Decrypt(serializer, keys.secretboxKey) - const encrypt = clientTestHelper.Encrypt(serializer, keys.secretboxKey) + const encrypt = cryptoUtil.Encrypt(serializer, keys.secretboxKey, 0) - t.plan(1) + t.plan(2) t.test('#put preference: device', (t) => { t.plan(2) const deviceId = new Uint8Array([0]) @@ -155,23 +153,46 @@ test('client RequestUtil', (t) => { .catch((error) => { t.fail(error) }) }) } - } - const testExpiredCredentials = (t, expiredCredentials, keys, serializer) => { + const expiredCredentials = { + aws: clientTestHelper.EXPIRED_CREDENTIALS.aws, + s3Post: clientTestHelper.EXPIRED_CREDENTIALS.s3Post, + bucket: requestUtil.bucket, + region: requestUtil.region + } t.test('RequestUtil with expired credentials', (t) => { - t.plan(1) - const userId = Buffer.from(keys.publicKey).toString('base64') - const args = [ + t.plan(2) + + const args = { + apiVersion: clientTestHelper.CONFIG.apiVersion, + credentialsBytes: serializer.credentialsToByteArray(expiredCredentials), + keys, serializer, - serializer.credentialsToByteArray(expiredCredentials), - clientTestHelper.CONFIG.apiVersion, - userId - ] - let requestUtil - t.doesNotThrow( - () => { requestUtil = new RequestUtil(...args) }, - `${t.name} instantiates without error` - ) + serverUrl: clientTestHelper.CONFIG.serverUrl + } + + t.doesNotThrow(() => { return new RequestUtil(args) }, `${t.name} instantiates without error`) + + t.test('#refreshAWSCredentials', (t) => { + t.plan(2) + const args = { + apiVersion: clientTestHelper.CONFIG.apiVersion, + keys, + serializer, + serverUrl: clientTestHelper.CONFIG.serverUrl + } + const requestUtil = new RequestUtil(args) + t.equals(requestUtil.s3, undefined, `${t.name} s3 is undefined`) + requestUtil.refreshAWSCredentials() + .then(() => { + requestUtil.list(proto.categories.PREFERENCES) + .then((response) => { + t.equals(response.length, 0, `${t.name} works`) + }) + .catch((error) => { t.fail(error) }) + }) + .catch((error) => { t.fail(error) }) + }) }) } }) diff --git a/test/client/testHelper.js b/test/client/testHelper.js index f8337d1..b9ac02a 100644 --- a/test/client/testHelper.js +++ b/test/client/testHelper.js @@ -16,10 +16,10 @@ module.exports.CONFIG = CONFIG */ const EXPIRED_CREDENTIALS = { aws: { - accessKeyId: "ASIAJWDZEARJIRDPKHCA", - secretAccessKey: "UWE6jt/VTgvyr9s1A8cGgQJKRZgpnSrFfNtbbVGQ", - sessionToken: "FQoDYXdzEPj//////////wEaDMcXJlV2DDdqPcV6tSKjA6DuDSolA5d1kxuuZiCbxPjR439unlaxtIIHLe/NCI9EbhxX2sRYW5ke244fobOYIgnyO9+cm28sqZdewM4LvYYgYwivwc3Ud9zmDzW14ZtJUSVXbj4WU0XHH106bV7RpQ52fTnG55sNfdhWndY1ptHBQzifWa3aKhGGQ4gpDPa+lGb5VaSlgMXqutzn8nVXA801MmwZXbfcT7QjloP8Qio4hnVQQptXfTlXoNsjPdmu7N8ZEUrnwZ4UmHIt4xbZ8GsM2YYBbuCroQjsvsc03eBhJ4HcAcx6G8W3pUDl8D0JbbgEEtwjwSHLJQ2tpUp8GKXPryp7NnU3HGAdgXWYZUY5AncXBySEZwF2PqjSuj3DuMQUuTURtvbbehKypFQ6ogAQb2OX7pzn8o/WnI/m7MS5rsIi9w0QdePzg6zGh4PtcHG4mpSqTbqsga6OQYLFW1d2DnS8hOz2h3cav3nmIF42r1/rLeiuqefUcwuVu6L9MCV7hw99rzUAOqQdKx8eh1bKU+lK1x3aypB2eLRicPyhOy3mUPs4JaDrqNnfwkViclN0KM3d0cIF", - expiration: "2016-12-25T04:20:00Z" + accessKeyId: 'ASIAJWDZEARJIRDPKHCA', + secretAccessKey: 'UWE6jt/VTgvyr9s1A8cGgQJKRZgpnSrFfNtbbVGQ', + sessionToken: 'FQoDYXdzEPj//////////wEaDMcXJlV2DDdqPcV6tSKjA6DuDSolA5d1kxuuZiCbxPjR439unlaxtIIHLe/NCI9EbhxX2sRYW5ke244fobOYIgnyO9+cm28sqZdewM4LvYYgYwivwc3Ud9zmDzW14ZtJUSVXbj4WU0XHH106bV7RpQ52fTnG55sNfdhWndY1ptHBQzifWa3aKhGGQ4gpDPa+lGb5VaSlgMXqutzn8nVXA801MmwZXbfcT7QjloP8Qio4hnVQQptXfTlXoNsjPdmu7N8ZEUrnwZ4UmHIt4xbZ8GsM2YYBbuCroQjsvsc03eBhJ4HcAcx6G8W3pUDl8D0JbbgEEtwjwSHLJQ2tpUp8GKXPryp7NnU3HGAdgXWYZUY5AncXBySEZwF2PqjSuj3DuMQUuTURtvbbehKypFQ6ogAQb2OX7pzn8o/WnI/m7MS5rsIi9w0QdePzg6zGh4PtcHG4mpSqTbqsga6OQYLFW1d2DnS8hOz2h3cav3nmIF42r1/rLeiuqefUcwuVu6L9MCV7hw99rzUAOqQdKx8eh1bKU+lK1x3aypB2eLRicPyhOy3mUPs4JaDrqNnfwkViclN0KM3d0cIF', + expiration: '2016-12-25T04:20:00Z' }, s3Post: { AWSAccessKeyId: 'AKIAIBFRINGWH5WYZLLQ', @@ -30,35 +30,6 @@ const EXPIRED_CREDENTIALS = { } module.exports.EXPIRED_CREDENTIALS = EXPIRED_CREDENTIALS -/** - * This is the browser JS version, wherein protobuf js records which take - * bytes can use Uint8Array. - * For the nodejs version, see ../testHelper.js. - * We could also combine these by using the library BytesBuffer. - */ -module.exports.encrypt = (serializer, secretboxKey, record) => { - const crypto = require('../../lib/crypto') - - const bytes = serializer.syncRecordToByteArray(record) - const nonceRandom = Buffer.from(crypto.randomBytes(20)) - const counter = 0 - const encrypted = crypto.encrypt(bytes, secretboxKey, counter, nonceRandom) - return serializer.SecretboxRecordToByteArray({ - encryptedData: encrypted.ciphertext, - counter, - nonceRandom: encrypted.nonce - }) -} - -/** - * Convenience wrapper. - */ -module.exports.Encrypt = (serializer, secretboxKey) => { - return (record) => { - return this.encrypt(serializer, secretboxKey, record) - } -} - /** * Generates keys and temporary AWS credentials. * Promise resolves with: diff --git a/test/server/users.js b/test/server/users.js index a0edf4e..f443245 100644 --- a/test/server/users.js +++ b/test/server/users.js @@ -16,7 +16,7 @@ test('users router', (t) => { const app = Express() app.use('/', usersRouter) const server = app.listen(0, 'localhost', () => { - serializer.init().then(() => { + serializer.init().then((serializer) => { const serverUrl = `http://localhost:${server.address().port}` console.log(`server up on ${serverUrl}`) const keys = crypto.deriveKeys(testHelper.cryptoSeed()) @@ -32,7 +32,7 @@ test('users router', (t) => { function signedTimestamp (secretKey, timestamp) { if (!timestamp) { timestamp = Math.floor(Date.now() / 1000) } const message = timestamp.toString() - return crypto.sign(serializer.serializer.stringToByteArray(message), secretKey) + return crypto.sign(serializer.stringToByteArray(message), secretKey) } t.test('POST /:userId/credentials', (t) => { @@ -61,7 +61,13 @@ test('users router', (t) => { let requester = null try { - requester = new RequestUtil(serializer.serializer, response.body, config.apiVersion, userId) + requester = new RequestUtil({ + apiVersion: config.apiVersion, + credentialsBytes: response.body, + keys, + serializer, + serverUrl: config.serverUrl + }) } catch (e) { t.fail(`Couldn't parse body / ${e}: ${response.body}`) }