diff --git a/lib/crypto/algorithms/base.js b/lib/crypto/algorithms/base.js index a7f04726702..1adec9c8b45 100644 --- a/lib/crypto/algorithms/base.js +++ b/lib/crypto/algorithms/base.js @@ -79,8 +79,20 @@ module.exports.EncryptionAlgorithm = EncryptionAlgorithm; * * @param {module:models/event.MatrixEvent} event event causing the change * @param {module:models/room-member} member user whose membership changed + * @param {string=} oldMembership previous membership */ -EncryptionAlgorithm.prototype.onRoomMembership = function(event, member) {}; +EncryptionAlgorithm.prototype.onRoomMembership = function( + event, member, oldMembership +) {}; + +/** + * Called when a new device announces itself in the room + * + * @param {string} userId owner of the device + * @param {string} deviceId deviceId of the device + */ +EncryptionAlgorithm.prototype.onNewDevice = function(userId, deviceId) {}; + /** * base type for decryption implementations diff --git a/lib/crypto/algorithms/megolm.js b/lib/crypto/algorithms/megolm.js index ae10d41cb2d..b107945246c 100644 --- a/lib/crypto/algorithms/megolm.js +++ b/lib/crypto/algorithms/megolm.js @@ -41,6 +41,12 @@ function MegolmEncryption(params) { this._prepPromise = null; this._outboundSessionId = null; this._discardNewSession = false; + + // devices which have joined since we last sent a message. + // userId -> {deviceId -> true}, or + // userId -> true + this._devicesPendingKeyShare = {}; + this._sharePromise = null; } utils.inherits(MegolmEncryption, base.EncryptionAlgorithm); @@ -49,20 +55,70 @@ utils.inherits(MegolmEncryption, base.EncryptionAlgorithm); * * @param {module:models/room} room * - * @return {module:client.Promise} Promise which resolves when setup is - * complete. + * @return {module:client.Promise} Promise which resolves to the megolm + * sessionId when setup is complete. */ MegolmEncryption.prototype._ensureOutboundSession = function(room) { + var self = this; + if (this._prepPromise) { // prep already in progress return this._prepPromise; } - if (this._outboundSessionId) { - // prep already done - return q(this._outboundSessionId); + var sessionId = this._outboundSessionId; + + // need to make a brand new session? + if (!sessionId) { + this._prepPromise = this._prepareNewSession(room). + finally(function() { + self._prepPromise = null; + }); + return this._prepPromise; + } + + if (this._sharePromise) { + // key share already in progress + return this._sharePromise; + } + + // prep already done, but check for new devices + var shareMap = this._devicesPendingKeyShare; + this._devicesPendingKeyShare = {}; + + // check each user is (still) a member of the room + for (var userId in shareMap) { + if (!shareMap.hasOwnProperty(userId)) { + continue; + } + + // XXX what about rooms where invitees can see the content? + var member = room.getMember(userId); + if (member.membership !== "join") { + delete shareMap[userId]; + } } + this._sharePromise = this._shareKeyWithDevices( + sessionId, shareMap + ).finally(function() { + self._sharePromise = null; + }).then(function() { + return sessionId; + }); + + return this._sharePromise; +}; + +/** + * @private + * + * @param {module:models/room} room + * + * @return {module:client.Promise} Promise which resolves to the megolm + * sessionId when setup is complete. + */ +MegolmEncryption.prototype._prepareNewSession = function(room) { var session_id = this._olmDevice.createOutboundGroupSession(); var key = this._olmDevice.getOutboundGroupSessionKey(session_id); @@ -71,48 +127,104 @@ MegolmEncryption.prototype._ensureOutboundSession = function(room) { key.key, key.chain_index ); - // send the keys to each (unblocked) device in the room. - var payload = { - type: "m.room_key", - content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: this._roomId, - session_id: session_id, - session_key: key.key, - chain_index: key.chain_index, - } - }; + // we're going to share the key with all current members of the room, + // so we can reset this. + this._devicesPendingKeyShare = {}; var roomMembers = utils.map(room.getJoinedMembers(), function(u) { return u.userId; }); + var shareMap = {}; + for (var i = 0; i < roomMembers.length; i++) { + var userId = roomMembers[i]; + shareMap[userId] = true; + } + var self = this; // TODO: we need to give the user a chance to block any devices or users // before we send them the keys; it's too late to download them here. - this._prepPromise = this._crypto.downloadKeys( + return this._crypto.downloadKeys( roomMembers, false ).then(function(res) { - return self._crypto.ensureOlmSessionsForUsers(roomMembers); - }).then(function(devicemap) { + return self._shareKeyWithDevices(session_id, shareMap); + }).then(function() { + if (self._discardNewSession) { + // we've had cause to reset the session_id since starting this process. + // we'll use the current session for any currently pending events, but + // don't save it as the current _outboundSessionId, so that new events + // will use a new session. + console.log("Session generation complete, but discarding"); + } else { + self._outboundSessionId = session_id; + } + return session_id; + }).finally(function() { + self._discardNewSession = false; + }); +}; + +/** + * @private + * + * @param {string} session_id + * @param {Object|boolean>} shareMap + * + * @return {module:client.Promise} Promise which resolves once the key sharing + * message has been sent. + */ +MegolmEncryption.prototype._shareKeyWithDevices = function(session_id, shareMap) { + var self = this; + + var key = this._olmDevice.getOutboundGroupSessionKey(session_id); + var payload = { + type: "m.room_key", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: this._roomId, + session_id: session_id, + session_key: key.key, + chain_index: key.chain_index, + } + }; + + return self._crypto.ensureOlmSessionsForUsers( + utils.keys(shareMap) + ).then(function(devicemap) { var contentMap = {}; + var haveTargets = false; for (var userId in devicemap) { if (!devicemap.hasOwnProperty(userId)) { continue; } - contentMap[userId] = {}; + var devicesToShareWith = shareMap[userId]; + var deviceInfos = devicemap[userId]; - var devices = devicemap[userId]; + for (var deviceId in deviceInfos) { + if (!deviceInfos.hasOwnProperty(deviceId)) { + continue; + } - for (var deviceId in devices) { - if (!devices.hasOwnProperty(deviceId)) { + if (devicesToShareWith === true) { + // all devices + } else if (!devicesToShareWith[deviceId]) { + // not a new device continue; } - var deviceInfo = devices[deviceId].device; + console.log( + "sharing keys with device " + userId + ":" + deviceId + ); + + var deviceInfo = deviceInfos[deviceId].device; + + if (!contentMap[userId]) { + contentMap[userId] = {}; + } + contentMap[userId][deviceId] = olmlib.encryptMessageForDevices( self._deviceId, @@ -120,28 +232,17 @@ MegolmEncryption.prototype._ensureOutboundSession = function(room) { [deviceInfo.getIdentityKey()], payload ); + haveTargets = true; } } + if (!haveTargets) { + return q(); + } + // TODO: retries return self._baseApis.sendToDevice("m.room.encrypted", contentMap); - }).then(function() { - if (self._discardNewSession) { - // we've had cause to reset the session_id since starting this process. - // we'll use the current session for any currently pending events, but - // don't save it as the current _outboundSessionId, so that new events - // will use a new session. - console.log("Session generation complete, but discarding"); - } else { - self._outboundSessionId = session_id; - } - return session_id; - }).finally(function() { - self._prepPromise = null; - self._discardNewSession = false; }); - - return this._prepPromise; }; /** @@ -182,30 +283,59 @@ MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) { * * @param {module:models/event.MatrixEvent} event event causing the change * @param {module:models/room-member} member user whose membership changed + * @param {string=} oldMembership previous membership */ -MegolmEncryption.prototype.onRoomMembership = function(event, member) { - // start a new outbound session whenever someone joins or leaves the room. - // - // technically we don't need to reset on all membership transitions (eg, - // leave->ban), but we might as well. +MegolmEncryption.prototype.onRoomMembership = function(event, member, oldMembership) { + var newMembership = member.membership; - // when people join the room, we could get away with sharing the current - // state of the ratchet with them; however, it's somewhat easier for now - // just to reset the session and start a new one. + if (newMembership === 'join') { + // new member in the room. + this._devicesPendingKeyShare[member.userId] = true; + return; + } + if (newMembership === 'invite' && oldMembership !== 'join') { + // we don't (yet) share keys with invited members, so nothing to do yet + return; + } + + // otherwise we assume the user is leaving, and start a new outbound session. if (this._outboundSessionId) { console.log("Discarding outbound megolm session due to change in " + - "membership of " + member.userId); + "membership of " + member.userId + " (" + oldMembership + + "->" + newMembership + ")"); this._outboundSessionId = null; } if (this._prepPromise) { console.log("Discarding as-yet-incomplete megolm session due to " + - "change in membership of " + member.userId); + "change in membership of " + member.userId + " (" + + oldMembership + "->" + newMembership + ")"); this._discardNewSession = true; } }; +/** + * @inheritdoc + * + * @param {string} userId owner of the device + * @param {string} deviceId deviceId of the device + */ +MegolmEncryption.prototype.onNewDevice = function(userId, deviceId) { + var d = this._devicesPendingKeyShare[userId]; + + if (d === true) { + // we already want to share keys with all devices for this user + return; + } + + if (!d) { + this._devicesPendingKeyShare[userId] = d = {}; + } + + d[deviceId] = true; +}; + /** * Megolm decryption implementation diff --git a/lib/crypto/index.js b/lib/crypto/index.js index 24b3245b6a5..7db5b3b0c04 100644 --- a/lib/crypto/index.js +++ b/lib/crypto/index.js @@ -88,6 +88,15 @@ function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId) { } function _registerEventHandlers(crypto, eventEmitter) { + eventEmitter.on("sync", function(syncState, oldState, data) { + if (syncState == "PREPARED") { + // XXX ugh. we're assuming the eventEmitter is a MatrixClient. + // how can we avoid doing so? + var rooms = eventEmitter.getRooms(); + crypto._onInitialSyncCompleted(rooms); + } + }); + eventEmitter.on( "RoomMember.membership", crypto._onRoomMembership.bind(crypto) @@ -96,6 +105,8 @@ function _registerEventHandlers(crypto, eventEmitter) { eventEmitter.on("toDeviceEvent", function(event) { if (event.getType() == "m.room_key") { crypto._onRoomKeyEvent(event); + } else if (event.getType() == "m.new_device") { + crypto._onNewDeviceEvent(event); } }); @@ -815,6 +826,73 @@ Crypto.prototype._onCryptoEvent = function(event) { } }; +/** + * handle the completion of the initial sync. + * + * Announces the new device. + * + * @private + * @param {module:models/room[]} rooms list of rooms the client knows about + */ +Crypto.prototype._onInitialSyncCompleted = function(rooms) { + if (this._sessionStore.getDeviceAnnounced()) { + return; + } + + // we need to tell all the devices in all the rooms we are members of that + // we have arrived. + // build a list of rooms for each user. + var roomsByUser = {}; + for (var i = 0; i < rooms.length; i++) { + var room = rooms[i]; + + // check for rooms with encryption enabled + var alg = this._roomAlgorithms[room.roomId]; + if (!alg) { + continue; + } + + // ignore any rooms which we have left + var me = room.getMember(this._userId); + if (!me || ( + me.membership !== "join" && me.membership !== "invite" + )) { + continue; + } + + var members = room.getJoinedMembers(); + for (var j = 0; j < members.length; j++) { + var m = members[j]; + if (!roomsByUser[m.userId]) { + roomsByUser[m.userId] = []; + } + roomsByUser[m.userId].push(room.roomId); + } + } + + // build a per-device message for each user + var content = {}; + for (var userId in roomsByUser) { + if (!roomsByUser.hasOwnProperty(userId)) { + continue; + } + content[userId] = { + "*": { + device_id: this._deviceId, + rooms: roomsByUser[userId], + }, + }; + } + + var self = this; + this._baseApis.sendToDevice( + "m.new_device", // OH HAI! + content + ).done(function() { + self._sessionStore.setDeviceAnnounced(); + }); +}; + /** * Handle a key event * @@ -841,8 +919,9 @@ Crypto.prototype._onRoomKeyEvent = function(event) { * @private * @param {module:models/event.MatrixEvent} event event causing the change * @param {module:models/room-member} member user whose membership changed + * @param {string=} oldMembership previous membership */ -Crypto.prototype._onRoomMembership = function(event, member) { +Crypto.prototype._onRoomMembership = function(event, member, oldMembership) { // this event handler is registered on the *client* (as opposed to the // room member itself), which means it is only called on changes to the @@ -859,7 +938,47 @@ Crypto.prototype._onRoomMembership = function(event, member) { return; } - alg.onRoomMembership(event, member); + alg.onRoomMembership(event, member, oldMembership); +}; + + +/** + * Called when a new device announces itself + * + * @private + * @param {module:models/event.MatrixEvent} event announcement event + */ +Crypto.prototype._onNewDeviceEvent = function(event) { + var content = event.getContent(); + var userId = event.getSender(); + var deviceId = content.device_id; + var rooms = content.rooms; + + if (!rooms || !deviceId) { + console.warn("new_device event missing keys"); + return; + } + + var self = this; + this.downloadKeys( + [userId], true + ).then(function() { + for (var i = 0; i < rooms.length; i++) { + var roomId = rooms[i]; + var alg = self._roomAlgorithms[roomId]; + if (!alg) { + // not encrypting in this room + continue; + } + alg.onNewDevice(userId, deviceId); + } + }).catch(function(e) { + console.error( + "Error updating device keys for new device " + userId + ":" + + deviceId, + e + ); + }).done(); }; /** diff --git a/lib/models/room-member.js b/lib/models/room-member.js index 03a2c455296..c9c5bac435b 100644 --- a/lib/models/room-member.js +++ b/lib/models/room-member.js @@ -80,11 +80,11 @@ RoomMember.prototype.setMembershipEvent = function(event, roomState) { this.name = calculateDisplayName(this, event, roomState); if (oldMembership !== this.membership) { this._updateModifiedTime(); - this.emit("RoomMember.membership", event, this); + this.emit("RoomMember.membership", event, this, oldMembership); } if (oldName !== this.name) { this._updateModifiedTime(); - this.emit("RoomMember.name", event, this); + this.emit("RoomMember.name", event, this, oldName); } }; @@ -255,6 +255,8 @@ module.exports = RoomMember; * @event module:client~MatrixClient#"RoomMember.name" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomMember} member The member whose RoomMember.name changed. + * @param {string?} oldName The previous name. Null if the member didn't have a + * name previously. * @example * matrixClient.on("RoomMember.name", function(event, member){ * var newName = member.name; @@ -266,8 +268,10 @@ module.exports = RoomMember; * @event module:client~MatrixClient#"RoomMember.membership" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomMember} member The member whose RoomMember.membership changed. + * @param {string?} oldMembership The previous membership state. Null if it's a + * new member. * @example - * matrixClient.on("RoomMember.membership", function(event, member){ + * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){ * var newState = member.membership; * }); */ diff --git a/lib/store/session/webstorage.js b/lib/store/session/webstorage.js index c98d2fbd1cf..3296f5d16a6 100644 --- a/lib/store/session/webstorage.js +++ b/lib/store/session/webstorage.js @@ -62,6 +62,22 @@ WebStorageSessionStore.prototype = { return this.store.getItem(KEY_END_TO_END_ACCOUNT); }, + /** + * Store a flag indicating that we have announced the new device. + */ + setDeviceAnnounced: function() { + this.store.setItem(KEY_END_TO_END_ANNOUNCED, "true"); + }, + + /** + * Check if the "device announced" flag is set + * + * @return {boolean} true if the "device announced" flag has been set. + */ + getDeviceAnnounced: function() { + return this.store.getItem(KEY_END_TO_END_ANNOUNCED) == "true"; + }, + /** * Stores the known devices for a user. * @param {string} userId The user's ID. @@ -134,6 +150,7 @@ WebStorageSessionStore.prototype = { }; var KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; +var KEY_END_TO_END_ANNOUNCED = E2E_PREFIX + "announced"; function keyEndToEndDevicesForUser(userId) { return E2E_PREFIX + "devices/" + userId;