diff --git a/lib/OlmDevice.js b/lib/OlmDevice.js new file mode 100644 index 000000000..b0a69f67f --- /dev/null +++ b/lib/OlmDevice.js @@ -0,0 +1,329 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +"use strict"; + +var Olm = require("olm"); +var utils = require("./utils"); + +/** + * Manages the olm cryptography functions. Each OlmDevice has a single + * OlmAccount and a number of OlmSessions. + * + * Accounts and sessions are kept pickled in a sessionStore. + * + * @constructor + * @param {Object} sessionStore A store to be used for data in end-to-end + * crypto + * + * @property {string} deviceCurve25519Key Curve25519 key for the account + * @property {string} deviceEd25519Key Ed25519 key for the account + */ +function OlmDevice(sessionStore) { + this._sessionStore = sessionStore; + this._pickleKey = "DEFAULT_KEY"; + + var e2eKeys; + var account = new Olm.Account(); + try { + var e2eAccount = this._sessionStore.getEndToEndAccount(); + if (e2eAccount === null) { + account.create(); + var pickled = account.pickle(this._pickleKey); + this._sessionStore.storeEndToEndAccount(pickled); + } else { + account.unpickle(this._pickleKey, e2eAccount); + } + e2eKeys = JSON.parse(account.identity_keys()); + } finally { + account.free(); + } + + this.deviceCurve25519Key = e2eKeys.curve25519; + this.deviceEd25519Key = e2eKeys.ed25519; +} + + +/** + * Signs a message with the ed25519 key for this account. + * + * @param {string} message message to be signed + * @return {string} base64-encoded signature + */ +OlmDevice.prototype.sign = function(message) { + var account = new Olm.Account(); + try { + var pickledAccount = this._sessionStore.getEndToEndAccount(); + account.unpickle(this._pickleKey, pickledAccount); + return account.sign(message); + } finally { + account.free(); + } +}; + +/** + * Get the current (unused, unpublished) one-time keys for this account. + * + * @return {object} one time keys; an object with the single property + * curve25519, which is itself an object mapping key id to Curve25519 + * key. + */ +OlmDevice.prototype.getOneTimeKeys = function() { + var account = new Olm.Account(); + try { + var pickledAccount = this._sessionStore.getEndToEndAccount(); + account.unpickle(this._pickleKey, pickledAccount); + return JSON.parse(account.one_time_keys()); + } finally { + account.free(); + } +}; + + +/** + * Get the maximum number of one-time keys we can store. + * + * @return {number} number of keys + */ +OlmDevice.prototype.maxNumberOfOneTimeKeys = function() { + var account = new Olm.Account(); + try { + var pickledAccount = this._sessionStore.getEndToEndAccount(); + account.unpickle(this._pickleKey, pickledAccount); + return account.max_number_of_one_time_keys(); + } finally { + account.free(); + } +}; + +/** + * Marks all of the one-time keys as published. + */ +OlmDevice.prototype.markKeysAsPublished = function() { + var account = new Olm.Account(); + try { + var pickledAccount = this._sessionStore.getEndToEndAccount(); + account.unpickle(this._pickleKey, pickledAccount); + account.mark_keys_as_published(); + pickledAccount = account.pickle(this._pickleKey); + this._sessionStore.storeEndToEndAccount(pickledAccount); + } finally { + account.free(); + } +}; + +/** + * Generate some new one-time keys + * + * @param {number} numKeys number of keys to generate + */ +OlmDevice.prototype.generateOneTimeKeys = function(numKeys) { + var account = new Olm.Account(); + try { + var pickledAccount = this._sessionStore.getEndToEndAccount(); + account.unpickle(this._pickleKey, pickledAccount); + account.generate_one_time_keys(numKeys); + pickledAccount = account.pickle(this._pickleKey); + this._sessionStore.storeEndToEndAccount(pickledAccount); + } finally { + account.free(); + } +}; + +/** + * Generate a new outbound session + * + * The new session will be stored in the sessionStore. + * + * @param {string} theirIdentityKey remote user's Curve25519 identity key + * @param {string} theirOneTimeKey remote user's one-time Curve25519 key + * @return {string} sessionId for the outbound session. + */ +OlmDevice.prototype.createOutboundSession = function( + theirIdentityKey, theirOneTimeKey +) { + var account = new Olm.Account(); + var session = new Olm.Session(); + try { + var pickledAccount = this._sessionStore.getEndToEndAccount(); + account.unpickle(this._pickleKey, pickledAccount); + + session.create_outbound(account, theirIdentityKey, theirOneTimeKey); + + var pickledSession = session.pickle(this._pickleKey); + var sessionId = session.session_id(); + this._sessionStore.storeEndToEndSession( + theirIdentityKey, sessionId, pickledSession + ); + return sessionId; + } finally { + session.free(); + account.free(); + } +}; + + +/** + * Generate a new inbound session, given an incoming message + * + * @param {string} theirDeviceIdentityKey remote user's Curve25519 identity key + * @param {number} message_type message_type field from the received message (must be 0) + * @param {string} ciphertext base64-encoded body from the received message + * + * @return {string} decrypted payload + * + * @raises {Error} if the received message was not valid (for instance, it + * didn't use a valid one-time key). + */ +OlmDevice.prototype.createInboundSession = function( + theirDeviceIdentityKey, message_type, ciphertext +) { + if (message_type !== 0) { + throw new Error("Need message_type == 0 to create inbound session"); + } + + var account = new Olm.Account(); + var session = new Olm.Session(); + try { + var pickledAccount = this._sessionStore.getEndToEndAccount(); + account.unpickle(this._pickleKey, pickledAccount); + + session.create_inbound_from(account, theirDeviceIdentityKey, ciphertext); + account.remove_one_time_keys(session); + + pickledAccount = account.pickle(this._pickleKey); + this._sessionStore.storeEndToEndAccount(pickledAccount); + + var payloadString = session.decrypt(message_type, ciphertext); + + var sessionId = session.session_id(); + var pickledSession = session.pickle(this._pickleKey); + this._sessionStore.storeEndToEndSession( + theirDeviceIdentityKey, sessionId, pickledSession + ); + + return payloadString; + } finally { + session.free(); + account.free(); + } +}; + + +/** + * Get a list of known session IDs for the given device + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * remote device + * @return {string[]} a list of known session ids for the device + */ +OlmDevice.prototype.getSessionIdsForDevice = function(theirDeviceIdentityKey) { + var sessions = this._sessionStore.getEndToEndSessions( + theirDeviceIdentityKey + ); + return utils.keys(sessions); +}; + + +/** + * Encrypt an outgoing message using an existing session + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * remote device + * @param {string} sessionId the id of the active session + * @param {string} payloadString payload to be encrypted and sent + * + * @return {string} ciphertext + */ +OlmDevice.prototype.encryptMessage = function( + theirDeviceIdentityKey, sessionId, payloadString +) { + var sessions = this._sessionStore.getEndToEndSessions( + theirDeviceIdentityKey + ); + var pickledSession = sessions[sessionId]; + + var session = new Olm.Session(); + + try { + session.unpickle(this._pickleKey, pickledSession); + var res = session.encrypt(payloadString); + pickledSession = session.pickle(this._pickleKey); + this._sessionStore.storeEndToEndSession( + theirDeviceIdentityKey, sessionId, pickledSession + ); + return res; + } finally { + session.free(); + } +}; + +/** + * Decrypt an incoming message using an existing session + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * remote device + * @param {string} sessionId the id of the active session + * @param {number} message_type message_type field from the received message + * @param {string} ciphertext base64-encoded body from the received message + * + * @return {string} decrypted payload + * + * @raises {Error} if the received message was not valid (for instance, it + * did not match this session). + */ +OlmDevice.prototype.decryptMessage = function( + theirDeviceIdentityKey, sessionId, message_type, ciphertext +) { + var sessions = this._sessionStore.getEndToEndSessions( + theirDeviceIdentityKey + ); + var pickledSession = sessions[sessionId]; + + var session = new Olm.Session(); + try { + session.unpickle(this._pickleKey, pickledSession); + var matchesInbound = message_type === 0 && session.matches_inbound(ciphertext); + var payloadString = null; + try { + payloadString = session.decrypt(message_type, ciphertext); + } catch (e) { + console.log( + "Failed to decrypt with an existing session: " + e.message + ); + + return { + matchesInbound: matchesInbound, + payload: null, + }; + } + + // successfully decrypted: update the session + pickledSession = session.pickle(this._pickleKey); + this._sessionStore.storeEndToEndSession( + theirDeviceIdentityKey, sessionId, pickledSession + ); + + return { + matchesInbound: matchesInbound, + payload: payloadString, + }; + } finally { + session.free(); + } +}; + +/** */ +module.exports = OlmDevice; diff --git a/lib/client.js b/lib/client.js index 5b8b71f75..bfb4efad8 100644 --- a/lib/client.js +++ b/lib/client.js @@ -41,10 +41,8 @@ var SCROLLBACK_DELAY_MS = 3000; var CRYPTO_ENABLED = false; try { - var Olm = require("olm"); - if (Olm.Account && Olm.Session) { - CRYPTO_ENABLED = true; - } + var OlmDevice = require("./OlmDevice"); + CRYPTO_ENABLED = true; } catch (e) { // Olm not installed. } @@ -69,10 +67,18 @@ var OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; * {@link requestFunction} for more information. * * @param {string} opts.accessToken The access_token for this user. + * * @param {string} opts.userId The user ID for this user. - * @param {Object} opts.store Optional. The data store to use. If not specified, + * + * @param {Object=} opts.store The data store to use. If not specified, * this client will not store any HTTP responses. * + * @param {string=} opts.deviceId A unique identifier for this device, for use + * in end-to-end crypto. If not specified, end-to-end crypto will be disabled. + * + * @param {Object=} opts.sessionStore A store to be used for end-to-end crypto + * session data. If not specified, end-to-end crypto will be disabled. + * * @param {Object} opts.scheduler Optional. The scheduler to use. If not * specified, this client will not retry requests on failure. This client * will supply its own processing function to @@ -97,47 +103,37 @@ function MatrixClient(opts) { this.store = opts.store || new StubStore(); this.sessionStore = opts.sessionStore || null; - this.accountKey = "DEFAULT_KEY"; + this.deviceId = opts.deviceId; + this._olmDevice = null; + if (CRYPTO_ENABLED && 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":["' + OLM_ALGORITHM + '"]'; - 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); - var myDevices = this.sessionStore.getEndToEndDevicesForUser( - opts.userId - ) || {}; - myDevices[opts.deviceId] = this.deviceKeys; - this.sessionStore.storeEndToEndDevicesForUser( - opts.userId, myDevices - ); - } finally { - account.free(); - } + this._olmDevice = new OlmDevice(opts.sessionStore); + + var json = '{"algorithms":["' + OLM_ALGORITHM + '"]'; + json += ',"device_id":"' + this.deviceId + '"'; + json += ',"keys":'; + json += '{"ed25519:' + this.deviceId + '":'; + json += JSON.stringify(this._olmDevice.deviceEd25519Key); + json += ',"curve25519:' + this.deviceId + '":'; + json += JSON.stringify(this._olmDevice.deviceCurve25519Key); + json += '}'; + json += ',"user_id":' + JSON.stringify(opts.userId); + json += '}'; + var signature = this._olmDevice.sign(json); + this.deviceKeys = JSON.parse(json); + var signatures = {}; + signatures[opts.userId] = {}; + signatures[opts.userId]["ed25519:" + this.deviceId] = signature; + this.deviceKeys.signatures = signatures; + + var myDevices = this.sessionStore.getEndToEndDevicesForUser( + opts.userId + ) || {}; + myDevices[opts.deviceId] = this.deviceKeys; + this.sessionStore.storeEndToEndDevicesForUser( + opts.userId, myDevices + ); } this.scheduler = opts.scheduler; if (this.scheduler) { @@ -311,54 +307,29 @@ MatrixClient.prototype.uploadKeys = function(maxKeys, deferred) { var self = this; return _doKeyUpload(this).then(function(res) { var keyCount = res.one_time_key_counts.curve25519 || 0; - - var pickled = self.sessionStore.getEndToEndAccount(); - - var numberToGenerate; - var account = new Olm.Account(); - try { - account.unpickle(self.accountKey, pickled); - - var maxOneTimeKeys = account.max_number_of_one_time_keys(); - var keyLimit = Math.floor(maxOneTimeKeys / 2); - numberToGenerate = Math.max(keyLimit - keyCount, 0); - if (maxKeys !== undefined) { - numberToGenerate = Math.min(numberToGenerate, maxKeys); - } - if (numberToGenerate > 0) { - account.generate_one_time_keys(numberToGenerate); - } - pickled = account.pickle(self.accountKey); - self.sessionStore.storeEndToEndAccount(pickled); - } finally { - account.free(); + var maxOneTimeKeys = self._olmDevice.maxNumberOfOneTimeKeys(); + var keyLimit = Math.floor(maxOneTimeKeys / 2); + var numberToGenerate = Math.max(keyLimit - keyCount, 0); + if (maxKeys !== undefined) { + numberToGenerate = Math.min(numberToGenerate, maxKeys); } - if (numberToGenerate > 0) { - return _doKeyUpload(self); - } else { + + if (numberToGenerate <= 0) { return; } + + self._olmDevice.generateOneTimeKeys(numberToGenerate); + return _doKeyUpload(self); }); }; // build the upload request, and return a promise which resolves to the response function _doKeyUpload(client) { - if (!CRYPTO_ENABLED || client.sessionStore === null) { + if (!client._olmDevice) { return q.reject(new Error("End-to-end encryption disabled")); } - var pickled = client.sessionStore.getEndToEndAccount(); - if (!pickled) { - return q.reject(new Error("End-to-end account not found")); - } - var account = new Olm.Account(); - var oneTimeKeys; - try { - account.unpickle(client.accountKey, pickled); - oneTimeKeys = JSON.parse(account.one_time_keys()); - } finally { - account.free(); - } + var oneTimeKeys = client._olmDevice.getOneTimeKeys(); var oneTimeJson = {}; for (var keyId in oneTimeKeys.curve25519) { @@ -374,15 +345,7 @@ function _doKeyUpload(client) { return client._http.authedRequestWithPrefix( undefined, "POST", path, undefined, content, httpApi.PREFIX_UNSTABLE ).then(function(res) { - var account = new Olm.Account(); - try { - account.unpickle(client.accountKey, pickled); - account.mark_keys_as_published(); - pickled = account.pickle(client.accountKey); - client.sessionStore.storeEndToEndAccount(pickled); - } finally { - account.free(); - } + client._olmDevice.markKeysAsPublished(); return res; }); } @@ -484,9 +447,10 @@ MatrixClient.prototype.listDeviceKeys = function(userId) { * @return {Object} A promise that will resolve when encryption is setup. */ MatrixClient.prototype.setRoomEncryption = function(roomId, config) { - if (!this.sessionStore || !CRYPTO_ENABLED) { + if (!this._olmDevice) { return q.reject(new Error("End-to-End encryption disabled")); } + if (config.algorithm === OLM_ALGORITHM) { if (!config.members) { throw new Error( @@ -505,7 +469,7 @@ MatrixClient.prototype.setRoomEncryption = function(roomId, config) { if (devices.hasOwnProperty(deviceId)) { var keys = devices[deviceId]; var key = keys.keys["curve25519:" + deviceId]; - if (key == this.deviceCurve25519Key) { + if (key == this._olmDevice.deviceCurve25519Key) { continue; } if (!this.sessionStore.getEndToEndSessions(key)) { @@ -543,21 +507,9 @@ MatrixClient.prototype.setRoomEncryption = function(roomId, config) { } } if (oneTimeKey) { - var session = new Olm.Session(); - var account = new Olm.Account(); - try { - var pickled = self.sessionStore.getEndToEndAccount(); - account.unpickle(self.accountKey, pickled); - session.create_outbound(account, device[2], oneTimeKey); - var sessionId = session.session_id(); - pickled = session.pickle(self.accountKey); - self.sessionStore.storeEndToEndSession( - device[2], sessionId, pickled - ); - } finally { - session.free(); - account.free(); - } + self._olmDevice.createOutboundSession( + device[2], oneTimeKey + ); } else { missing[device[0]] = missing[device[0]] || []; missing[device[0]].push([device[1]]); @@ -1032,18 +984,10 @@ function _encryptMessage(client, roomId, e2eRoomInfo, eventType, content, var payloadString = JSON.stringify(payloadJson); for (i = 0; i < participantKeys.length; ++i) { var deviceKey = participantKeys[i]; - if (deviceKey == client.deviceCurve25519Key) { + if (deviceKey == client._olmDevice.deviceCurve25519Key) { continue; } - var sessions = client.sessionStore.getEndToEndSessions( - deviceKey - ); - var sessionIds = []; - for (var sessionId in sessions) { - if (sessions.hasOwnProperty(sessionId)) { - sessionIds.push(sessionId); - } - } + var sessionIds = client._olmDevice.getSessionIdsForDevice(deviceKey); // Use the session with the lowest ID. sessionIds.sort(); if (sessionIds.length === 0) { @@ -1051,22 +995,14 @@ function _encryptMessage(client, roomId, e2eRoomInfo, eventType, content, // we can't encrypt a message for it. continue; } - 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( - deviceKey, sessionId, pickled - ); - } finally { - session.free(); - } + var sessionId = sessionIds[0]; + ciphertext[deviceKey] = client._olmDevice.encryptMessage( + deviceKey, sessionId, payloadString + ); } var encryptedContent = { algorithm: e2eRoomInfo.algorithm, - sender_key: client.deviceCurve25519Key, + sender_key: client._olmDevice.deviceCurve25519Key, ciphertext: ciphertext }; return encryptedContent; @@ -1090,59 +1026,38 @@ function _decryptMessage(client, event) { if (!ciphertext) { return _badEncryptedMessage(event, "**Missing ciphertext**"); } - if (!(client.deviceCurve25519Key in content.ciphertext)) { + if (!(client._olmDevice.deviceCurve25519Key in content.ciphertext)) { return _badEncryptedMessage(event, "**Not included in recipients**"); } - var message = content.ciphertext[client.deviceCurve25519Key]; - var sessions = client.sessionStore.getEndToEndSessions(deviceKey); + var message = content.ciphertext[client._olmDevice.deviceCurve25519Key]; + var sessionIds = client._olmDevice.getSessionIdsForDevice(deviceKey); var payloadString = null; var foundSession = false; - var session; - for (var sessionId in sessions) { - if (sessions.hasOwnProperty(sessionId)) { - session = new Olm.Session(); - try { - session.unpickle(client.accountKey, sessions[sessionId]); - if (message.type === 0 && session.matches_inbound(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. - console.log( - "Failed to decrypt with an existing session: " + e.message - ); - } finally { - session.free(); - } + for (var i = 0; i < sessionIds.length; i++) { + var sessionId = sessionIds[i]; + var res = client._olmDevice.decryptMessage( + deviceKey, sessionId, message.type, message.body + ); + payloadString = res.payload; + if (payloadString) { + break; + } + + if (res.matchesInbound) { + // this was a prekey message which matched this session; don't + // create a new session. + foundSession = true; + break; } } if (message.type === 0 && !foundSession && payloadString === null) { - var account = new Olm.Account(); - session = new 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); - sessionId = session.session_id(); - client.sessionStore.storeEndToEndSession( - deviceKey, sessionId, pickledSession + payloadString = client._olmDevice.createInboundSession( + deviceKey, message.type, message.body ); - client.sessionStore.storeEndToEndAccount(pickledAccount); } catch (e) { // Failed to decrypt with a new session. - } finally { - session.free(); - account.free(); } } @@ -3093,8 +3008,8 @@ MatrixClient.prototype.startClient = function(opts) { this._clientOpts = opts; - if (CRYPTO_ENABLED && this.sessionStore !== null) { - this.uploadKeys(5); + if (this._olmDevice) { + this.uploadKeys(5).done(); } // periodically poll for turn servers if we support voip diff --git a/spec/integ/matrix-client-crypto.spec.js b/spec/integ/matrix-client-crypto.spec.js index ec2bc112b..6fcd6b93c 100644 --- a/spec/integ/matrix-client-crypto.spec.js +++ b/spec/integ/matrix-client-crypto.spec.js @@ -88,10 +88,6 @@ describe("MatrixClient crypto", function() { expect(aliClient.deviceKeys.device_id).toEqual(aliDeviceId); done(); }); - it("should have a curve25519 key", function(done) { - expect(aliClient.deviceCurve25519Key).toBeDefined(); - done(); - }); }); function bobUploadsKeys() {