From 2ee5977ad267d509dbf2ed80b64fc52e6e8d6929 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 16 Jul 2015 18:21:25 +0100 Subject: [PATCH] Start integrating end-to-end into the matrix-client. Add a storage class to store end-to-end sessions. Implement the one-time key upload API, and start sketching out the encryption and decryption functions --- lib/client.js | 297 ++++++++++++++++++++++++++++++-- lib/matrix.js | 2 + lib/store/session/webstorage.js | 142 +++++++++++++++ spec/mock-request.js | 2 +- 4 files changed, 429 insertions(+), 14 deletions(-) create mode 100644 lib/store/session/webstorage.js diff --git a/lib/client.js b/lib/client.js index 856ecb3ad..290fab737 100644 --- a/lib/client.js +++ b/lib/client.js @@ -17,6 +17,9 @@ var Room = require("./models/room"); var User = require("./models/user"); var utils = require("./utils"); +// TODO: package this somewhere separate. +var Olm = require("./olm"); + // TODO: // Internal: rate limiting @@ -44,11 +47,48 @@ var utils = require("./utils"); */ function MatrixClient(opts) { utils.checkObjectHasKeys(opts, ["baseUrl", "request"]); - utils.checkObjectHasNoAdditionalKeys(opts, - ["baseUrl", "request", "accessToken", "userId", "store", "scheduler"] - ); + utils.checkObjectHasNoAdditionalKeys(opts, [ + "baseUrl", "request", "accessToken", "userId", "store", "scheduler", + "sessionStore", "deviceId" + ]); this.store = opts.store || new StubStore(); + this.sessionStore = opts.sessionStore || null; + this.accountKey = "DEFAULT_KEY"; + this.deviceId = opts.deviceId; + if (this.sessionStore !== null) { + var e2eAccount = this.sessionStore.getEndToEndAccount(); + var account = new Olm.Account(); + try { + if (e2eAccount == null) { + account.create(); + } else { + account.unpickle(this.accountKey, e2eAccount); + } + var e2eKeys = JSON.parse(account.identity_keys()); + var json = '{"algorithms":["m.olm.v1.curve25519-aes-sha2"]'; + json += ',"device_id":"' + this.deviceId + '"'; + json += ',"keys":'; + json += '{"ed25519:' + this.deviceId + '":'; + json += JSON.stringify(e2eKeys.ed25519); + json += ',"curve25519:' + this.deviceId + '":'; + json += JSON.stringify(e2eKeys.curve25519); + json += '}'; + json += ',"user_id":' + JSON.stringify(opts.userId); + json += '}'; + var signature = account.sign(json); + this.deviceKeys = JSON.parse(json); + var signatures = {}; + signatures[opts.userId] = {}; + signatures[opts.userId]["ed25519:" + this.deviceId] = signature; + this.deviceKeys.signatures = signatures; + this.deviceCurve25519Key = e2eKeys["curve25519"]; + var pickled = account.pickle(this.accountKey); + this.sessionStore.storeEndToEndAccount(pickled); + } finally { + account.free(); + } + } this.scheduler = opts.scheduler; if (this.scheduler) { var self = this; @@ -76,6 +116,61 @@ function MatrixClient(opts) { } utils.inherits(MatrixClient, EventEmitter); +MatrixClient.prototype.uploadKeys = function(deferred) { + var first_time = deferred === undefined; + deferred = deferred || q.defer(); + var path = "/keys/upload/" + this.deviceId; + var pickled = this.sessionStore.getEndToEndAccount(); + var account = new Olm.Account(); + try { + account.unpickle(this.accountKey, pickled); + var oneTimeKeys = JSON.parse(account.one_time_keys()); + var maxOneTimeKeys = account.max_number_of_one_time_keys(); + } finally { + account.free(); + } + var oneTimeJson = {}; + + for (var keyId in oneTimeKeys.curve25519) { + oneTimeJson["curve25519:" + keyId] = oneTimeKeys.curve25519[keyId]; + } + var content = { + device_keys: this.deviceKeys, + one_time_keys: oneTimeJson + }; + var self = this; + this._http.authedRequestWithPrefix( + undefined, "POST", path, undefined, content, httpApi.PREFIX_V2_ALPHA + ).then(function(res) { + var keyLimit = Math.floor(maxOneTimeKeys / 2); + var keyCount = res.one_time_key_counts.curve25519 || 0; + var generateKeys = (keyCount < keyLimit); + var pickled = self.sessionStore.getEndToEndAccount(); + + var account = new Olm.Account(); + try { + account.unpickle(self.accountKey, pickled); + account.mark_keys_as_published(); + if (generateKeys) { + account.generate_one_time_keys(keyLimit - keyCount); + } + pickled = account.pickle(self.accountKey); + self.sessionStore.storeEndToEndAccount(pickled); + } finally { + account.free(); + } + if (generateKeys && first_time) { + self.uploadKeys(deferred); + } else { + deferred.resolve(); + } + }); + return deferred.promise; +}; + + + + /** * Get the room for the given room ID. * @param {string} roomId The room ID @@ -267,6 +362,15 @@ MatrixClient.prototype.sendStateEvent = function(roomId, eventType, content, sta */ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId, callback) { + if (eventType === "m.room.message" && this.sessionStore) { + var e2eRoomInfo = this.sessionStore.getEndToEndRoom(roomId); + if (e2eRoomInfo) { + return _sendEncryptedMessage( + client, roomId, e2eRoomInfo, content, txnId, callback + ); + } + } + if (utils.isFunction(txnId)) { callback = txnId; txnId = undefined; } if (!txnId) { txnId = "m" + new Date().getTime(); @@ -295,6 +399,164 @@ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId, return _sendEvent(this, room, localEvent, callback); }; +function _sendEncryptedMessage(client, roomId, e2eRoomInfo, eventType, content, + txnId, callback) { + if (!client.sessionStore) { + throw new Error( + "Client must have an end-to-end session store to encrypt messages" + ); + } + + if (e2eRoomInfo.algorithm === "m.olm.v1.curve25519-aes-sha2") { + var participantKeys = []; + for (var i = 0; i < e2eRoomInfo.participants.length; ++i) { + var userId = e2eRoomInfo.participants[i]; + var userCiphertext = {}; + devices = client.sessionStore.getEndToEndDevicesForUser(userId); + for (var deviceId in devices) { + var keys = devices[deviceId]; + for (keyId in keys.keys) { + if (keyId.startsWith("curve25519")) { + participantKeys.push(keys.keys[keyId]); + } + } + } + } + participantKeys.sort(); + var participantHash = ""; // Olm.sha256(participantKeys.join()); + var payloadJson = { + roomId: roomId, + type: eventType, + fingerprint: participantHash, + sender_device: client.deviceId, + content: content + }; + var ciphertext = {}; + var payloadString = JSON.stringify(payloadJson); + for (var i = 0; i < participantKeys.length(); ++i) { + var deviceKey = participantKeys[i]; + var sessions = client.sessionStore.getEndToEndSessions( + deviceKey + ); + var sessionIds = []; + for (sessionId in sessions) { + sessionIds.push(sessionId); + } + // Use the session with the lowest ID. + sessionIds.sort() + if (sessionIds.length == 0) { + // If we don't have a session for a device then + // we can't encrypt a message for it. + continue; + } + var sessionId = sessionIds[0]; + var session = new Olm.Session(); + try { + session.unpickle(client.accountKey, sessions[sessionId]); + ciphertext[deviceKey] = session.encrypt(payloadString); + var pickled = session.pickle(client.accountKey); + client.sessionStore.storeEndToEndSession( + userId, deviceId, sessionId, pickled + ); + } finally { + session.free(); + } + } + var encryptedContent = { + algorithm: e2eRoomInfo.algorithm, + sender_key: client.deviceCurve25519Key, + ciphertext: ciphertext + }; + return client.sendEvent( + roomId, "m.room.encrypted", encryptedContent, txnId, callback + ); + } else { + throw new Error("Unknown end-to-end algorithm"); + } +} + +function _decryptMessage(client, event) { + var content = event.getContent(); + if (content.algorithm === "m.olm.v1.curve25519-aes-sha2") { + var sender = event.getSender(); + var deviceKey = content.sender_key; + if (!client.deviceCurve25519Key in content.ciphertext) { + return _badEncryptedMessage(event, "Not included in recipients"); + } + var message = content.ciphertext[client.deviceCurve25519Key]; + var sessions = client.sessionStore.getEndToEndSessions(deviceKey); + var payloadString = null; + var foundSession = false; + for (sessionId in sessions) { + var session = new Olm.Session(); + try { + session.unpickle(client.accountKey, sessions[sessionId]); + if (message.type == 0 && session.matches(message.body)) { + foundSession = true; + } + payloadString = session.decrypt(message.type, message.body); + var pickled = session.pickle(client.accountKey); + client.sessionStore.storeEndToEndSession( + deviceKey, sessionId, pickled + ); + } catch(e) { + // Failed to decrypt with an existing session. + } finally { + session.free(); + } + } + + if (message.type == 0 && !foundSession && payloadString !== null) { + var account = Olm.Account(); + var session = Olm.Session(); + try { + var account_data = client.sessionStore.getEndToEndAccount(); + account.unpickle(client.accountKey, account_data); + session.create_inbound_from(account, deviceKey, message.body); + payloadString = session.decrypt(message.type, message.body); + account.remove_one_time_keys(session); + var pickledSession = session.pickle(client.accountKey); + var pickledAccount = account.pickle(client.accountKey); + var sessionId = session.session_id(); + client.sessionStore.storeEndToEndSession( + deviceKey, sessionId, pickledSession + ); + client.sessionStore.storeEndToEndAccount(pickledAccount); + } catch(e) { + // Failed to decrypt with a new session. + } finally { + session.free(); + account.free(); + } + } + + if (payloadString !== null) { + var payload = JSON.parse(payloadString); + return new MatrixEvent({ + // TODO: Add rest of the event keys. + // TODO: Add a key to indicate that the event was encrypted. + type: payload.type, + content: payload.content, + user_id: event.getSender() + }); + } else { + return _badEncryptedMessge(event, "Bad Encrypted Message"); + } + } +} + +function _badEncryptedMessge(event, reason) { + return new MatrixEvent({ + type: "m.room.message", + // TODO: Add rest of the event keys. + content: { + msgtype: "m.bad.encrypted", + body: reason, + content: event.getContent() + } + }); +} + function _sendEvent(client, room, event, callback) { var defer = q.defer(); var promise; @@ -900,7 +1162,7 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) { var defer = q.defer(); var self = this; this._http.authedRequest(callback, "GET", path, params).done(function(res) { - var matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper); + var matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self)); room.addEventsToTimeline(matrixEvents, true); room.oldState.paginationToken = res.end; if (res.chunk.length < limit) { @@ -1055,7 +1317,8 @@ function doInitialSync(client, historyLen) { var i, j; // intercept the results and put them into our store if (!(client.store instanceof StubStore)) { - utils.forEach(utils.map(data.presence, _PojoToMatrixEventMapper), + utils.forEach( + utils.map(data.presence, _PojoToMatrixEventMapper(client)), function(e) { var user = createNewUser(client, e.getContent().user_id); user.setPresenceEvent(e); @@ -1081,7 +1344,7 @@ function doInitialSync(client, historyLen) { } _processRoomEvents( - room, data.rooms[i].state, data.rooms[i].messages + client, room, data.rooms[i].state, data.rooms[i].messages ); // cache the name/summary/etc prior to storage since we don't @@ -1266,7 +1529,7 @@ function _syncRoom(client, room) { client._syncingRooms[room.roomId] = defer.promise; client.roomInitialSync(room.roomId, 8).done(function(res) { room.timeline = []; // blow away any previous messages. - _processRoomEvents(room, res.state, res.messages); + _processRoomEvents(client, room, res.state, res.messages); room.recalculate(client.credentials.userId); client.store.storeRoom(room); client.emit("Room", room); @@ -1279,15 +1542,15 @@ function _syncRoom(client, room) { return defer.promise; } -function _processRoomEvents(room, stateEventList, messageChunk) { +function _processRoomEvents(client, room, stateEventList, messageChunk) { // "old" and "current" state are the same initially; they // start diverging if the user paginates. // We must deep copy otherwise membership changes in old state // will leak through to current state! var oldStateEvents = utils.map( - utils.deepCopy(stateEventList), _PojoToMatrixEventMapper + utils.deepCopy(stateEventList), _PojoToMatrixEventMapper(client) ); - var stateEvents = utils.map(stateEventList, _PojoToMatrixEventMapper); + var stateEvents = utils.map(stateEventList, _PojoToMatrixEventMapper(client)); room.oldState.setStateEvents(oldStateEvents); room.currentState.setStateEvents(stateEvents); @@ -1299,7 +1562,7 @@ function _processRoomEvents(room, stateEventList, messageChunk) { room.addEventsToTimeline( utils.map( messageChunk ? messageChunk.chunk : [], - _PojoToMatrixEventMapper + _PojoToMatrixEventMapper(client) ).reverse(), true ); if (messageChunk) { @@ -1377,8 +1640,16 @@ function _resolve(callback, defer, res) { defer.resolve(res); } -function _PojoToMatrixEventMapper(plainOldJsObject) { - return new MatrixEvent(plainOldJsObject); +function _PojoToMatrixEventMapper(client) { + function mapper (plainOldJsObject) { + var event = new MatrixEvent(plainOldJsObject); + if (event.getType() === "m.room.encrypted") { + return _decryptMessage(client, event); + } else { + return event; + } + } + return mapper; } /** */ diff --git a/lib/matrix.js b/lib/matrix.js index ba03067b9..c805293e3 100644 --- a/lib/matrix.js +++ b/lib/matrix.js @@ -24,6 +24,8 @@ module.exports.RoomState = require("./models/room-state"); module.exports.User = require("./models/user"); /** The {@link module:scheduler~MatrixScheduler|MatrixScheduler} class. */ module.exports.MatrixScheduler = require("./scheduler"); +/** The {@link module:store/session/webstorage.WebStorageSessionStore|WebStorageSessionStore} class */ +module.exports.WebStorageSessionStore = require("./store/session/webstorage"); // expose the underlying request object so different environments can use // different request libs (e.g. request or browser-request) diff --git a/lib/store/session/webstorage.js b/lib/store/session/webstorage.js new file mode 100644 index 000000000..8eb029729 --- /dev/null +++ b/lib/store/session/webstorage.js @@ -0,0 +1,142 @@ +"use strict"; + +var utils = require("../../utils"); + +var DEBUG = false; // set true to enable console logging. +var E2E_PREFIX = "session.e2e."; + +/** + * Construct a web storage session store, capable of storing account keys, + * session keys and access tokens. + * @constructor + * @param {WebStorage} webStore A web storage implementation, e.g. + * 'window.localStorage' or 'window.sessionStorage' or a custom implementation. + * @throws if the supplied 'store' does not meet the Storage interface of the + * WebStorage API. + */ +function WebStorageSessionStore(webStore) { + this.store = webStore; + if (!utils.isFunction(webStore.getItem) + || !utils.isFunction(webStore.setItem) + || !utils.isFunction(webStore.removeItem)) { + throw new Error( + "Supplied webStore does not meet the WebStorage API interface" + ); + } +} + +WebStorageSessionStore.prototype = { + + /** + * Store the end to end account for the logged-in user. + * @param {string} account Base64 encoded account. + */ + storeEndToEndAccount: function(account) { + this.store.setItem(KEY_END_TO_END_ACCOUNT, account); + }, + + /** + * Load the end to end account for the logged-in user. + * @return {?string} Base64 encoded account. + */ + getEndToEndAccount: function() { + return this.store.getItem(KEY_END_TO_END_ACCOUNT); + }, + + /** + * Stores the known devices for a user. + * @param {string} userId The user's ID. + * @param {object} devices A map from device ID to keys for the device. + */ + storeEndToEndDevicesForUser : function(userId, devices) { + setJsonItem(this.store, keyEndToEndDevicesForUser(userId), devices); + }, + + /** + * Retrieves the known devices for a user. + * @param {string} userId The user's ID. + * @return {object} A map from device ID to keys for the device. + */ + getEndToEndDevicesForUser: function(userId) { + return getJsonItem(this.store, keyEndToEndDevicesForUser(userId)) + }, + + /** + * Store a session between the logged-in user and another device + * @param {string} deviceKey The public key of the other device. + * @param {string} sessionId The ID for this end-to-end session. + * @param {string} session Base64 encoded end-to-end session. + */ + storeEndToEndSession: function(deviceKey, sessionId, session) { + sessions = this.getEndToEndSessions(deviceKey); + sessions[sessionId] = session; + setJsonItem( + this.store, keyEndToEndSessions(deviceKey), sessions + ); + }, + + /** + * Retrieve the end-to-end sessions between the logged-in user and another + * device. + * @param {string} deviceKey The public key of the other device. + * @return {object} A map from sessionId to Base64 end-to-end session. + */ + getEndToEndSessions: function(deviceKey) { + return getJsonItem(this.store, keyEndToEndSessions(deviceKey)); + }, + + /** + * Store the end-to-end state for a room. + * @param {string} roomId The room's ID. + * @param {object} roomInfo The end-to-end info for the room. + */ + storeEndToEndRoom: function(roomId, roomInfo) { + setJsonItem(this.store, keyEndToEndRoom(roomId), roomInfo); + }, + + /** + * Get the end-to-end state for a room + * @param {string} roomId The room's ID. + * @return {object} The end-to-end info for the room. + */ + getEndToEndRoom: function(roomId) { + return getJsonItem(this.store, keyEndToEndRoom(roomId)); + } +} + +var KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; + +function keyEndToEndDevicesForUser(userId) { + return E2E_PREFIX + "devices/" + userId; +} + +function keyEndToEndSessions(deviceKey) { + return E2E_PREFIX + "sessions/" + deviceKey; +} + +function keyEndToEndRoom(roomId) { + return E2E_PREFIX + "rooms/" + roomId; +} + +function getJsonItem(store, key) { + try { + return JSON.parse(store.getItem(key)); + } + catch (e) { + debuglog("Failed to get key %s: %s", key, e); + debuglog(e.stack); + } + return null; +} + +function setJsonItem(store, key, val) { + store.setItem(key, JSON.stringify(val)); +} + +function debuglog() { + if (DEBUG) { + console.log.apply(console, arguments); + } +} + +module.exports = WebStorageSessionStore; diff --git a/spec/mock-request.js b/spec/mock-request.js index 1ca36c9f1..4e10f6275 100644 --- a/spec/mock-request.js +++ b/spec/mock-request.js @@ -96,7 +96,7 @@ HttpBackend.prototype = { console.log(" responding to %s", matchingReq.path); var body = testResponse.body; if (Object.prototype.toString.call(body) == "[object Function]") { - body = body(); + body = body(req.path, req.data); } req.callback( testResponse.err, testResponse.response, body