Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

E2E: Check devices to share keys with on each send #295

Merged
merged 2 commits into from Nov 17, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
223 changes: 97 additions & 126 deletions lib/crypto/algorithms/megolm.js
Expand Up @@ -38,12 +38,17 @@ var base = require("./base");
* @property {Number} creationTime when the session was created (ms since the epoch)
* @property {module:client.Promise?} sharePromise If a share operation is in progress,
* a promise which resolves when it is complete.
*
* @property {object} sharedWithDevices
* devices with which we have shared the session key
* userId -> {deviceId -> msgindex}
*/
function OutboundSessionInfo(sessionId) {
this.sessionId = sessionId;
this.useCount = 0;
this.creationTime = new Date().getTime();
this.sharePromise = null;
this.sharedWithDevices = {};
}


Expand Down Expand Up @@ -90,11 +95,6 @@ function MegolmEncryption(params) {
// case _outboundSession.sharePromise will be non-null.)
this._outboundSession = null;

// devices which have joined since we last sent a message.
// userId -> {deviceId -> true}, or
// userId -> true
this._devicesPendingKeyShare = {};

// default rotation periods
this._sessionRotationPeriodMsgs = 100;
this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000;
Expand Down Expand Up @@ -134,32 +134,55 @@ MegolmEncryption.prototype._ensureOutboundSession = function(room) {
return session.sharePromise;
}

// no share in progress: check for new devices
var shareMap = this._devicesPendingKeyShare;
this._devicesPendingKeyShare = {};
// no share in progress: check if we need to share with any devices
var prom = this._getDevicesInRoom(room).then(function(devicesInRoom) {
var shareMap = {};

// check each user is (still) a member of the room
for (var userId in shareMap) {
if (!shareMap.hasOwnProperty(userId)) {
continue;
}
for (var userId in devicesInRoom) {
if (!devicesInRoom.hasOwnProperty(userId)) {
continue;
}

var userDevices = devicesInRoom[userId];

for (var deviceId in userDevices) {
if (!userDevices.hasOwnProperty(deviceId)) {
continue;
}

var deviceInfo = userDevices[deviceId];

// XXX what about rooms where invitees can see the content?
var member = room.getMember(userId);
if (member.membership !== "join") {
delete shareMap[userId];
if (deviceInfo.isBlocked()) {
continue;
}

var key = deviceInfo.getIdentityKey();
if (key == self._olmDevice.deviceCurve25519Key) {
// don't bother sending to ourself
continue;
}

if (
!session.sharedWithDevices[userId] ||
session.sharedWithDevices[userId][deviceId] === undefined
) {
shareMap[userId] = shareMap[userId] || [];
shareMap[userId].push(deviceInfo);
}
}
}
}

session.sharePromise = this._shareKeyWithDevices(
session.sessionId, shareMap
).finally(function() {
return self._shareKeyWithDevices(
session, shareMap
);
}).finally(function() {
session.sharePromise = null;
}).then(function() {
return session;
});

return session.sharePromise;
session.sharePromise = prom;
return prom;
};

/**
Expand All @@ -178,95 +201,53 @@ MegolmEncryption.prototype._prepareNewSession = function(room) {
key.key, {ed25519: this._olmDevice.deviceEd25519Key}
);

// we're going to share the key with all current members of the room,
// so we can reset this.
this._devicesPendingKeyShare = {};

var session = new OutboundSessionInfo(session_id);

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.
session.sharePromise = this._crypto.downloadKeys(
roomMembers, false
).then(function(res) {
return self._shareKeyWithDevices(session_id, shareMap);
}).then(function() {
return session;
}).finally(function() {
session.sharePromise = null;
});

return session;
return new OutboundSessionInfo(session_id);
};

/**
* @private
*
* @param {string} session_id
* @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session
*
* @param {Object<string, Object<string, boolean>|boolean>} shareMap
* Map from userid to either: true (meaning this is a new user in the room,
* so all of his devices need the keys); or a map from deviceid to true
* (meaning this user has one or more new devices, which need the keys).
* @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
* map from userid to list of devices
*
* @return {module:client.Promise} Promise which resolves once the key sharing
* message has been sent.
*/
MegolmEncryption.prototype._shareKeyWithDevices = function(session_id, shareMap) {
MegolmEncryption.prototype._shareKeyWithDevices = function(session, devicesByUser) {
var self = this;

var key = this._olmDevice.getOutboundGroupSessionKey(session_id);
var key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId);
var payload = {
type: "m.room_key",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: this._roomId,
session_id: session_id,
session_id: session.sessionId,
session_key: key.key,
chain_index: key.chain_index,
}
};

// we downloaded the user's device list when they joined the room, or when
// the new device announced itself, so there is no need to do so now.
var contentMap = {};

return self._crypto.ensureOlmSessionsForUsers(
utils.keys(shareMap)
return olmlib.ensureOlmSessionsForDevices(
this._olmDevice, this._baseApis, devicesByUser
).then(function(devicemap) {
var contentMap = {};
var haveTargets = false;

for (var userId in devicemap) {
if (!devicemap.hasOwnProperty(userId)) {
for (var userId in devicesByUser) {
if (!devicesByUser.hasOwnProperty(userId)) {
continue;
}

var devicesToShareWith = shareMap[userId];
var devicesToShareWith = devicesByUser[userId];
var sessionResults = devicemap[userId];

for (var deviceId in sessionResults) {
if (!sessionResults.hasOwnProperty(deviceId)) {
continue;
}

if (devicesToShareWith === true) {
// all devices
} else if (!devicesToShareWith[deviceId]) {
// not a new device
continue;
}
for (var i = 0; i < devicesToShareWith.length; i++) {
var deviceInfo = devicesToShareWith[i];
var deviceId = deviceInfo.deviceId;

var sessionResult = sessionResults[deviceId];
if (!sessionResult.sessionId) {
Expand All @@ -288,8 +269,6 @@ MegolmEncryption.prototype._shareKeyWithDevices = function(session_id, shareMap)
"sharing keys with device " + userId + ":" + deviceId
);

var deviceInfo = sessionResult.device;

var encryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: self._olmDevice.deviceCurve25519Key,
Expand Down Expand Up @@ -321,6 +300,27 @@ MegolmEncryption.prototype._shareKeyWithDevices = function(session_id, shareMap)

// TODO: retries
return self._baseApis.sendToDevice("m.room.encrypted", contentMap);
}).then(function() {
// Add the devices we have shared with to session.sharedWithDevices.
//
// we deliberately iterate over devicesByUser (ie, the devices we
// attempted to share with) rather than the contentMap (those we did
// share with), because we don't want to try to claim a one-time-key
// for dead devices on every message.
for (var userId in devicesByUser) {
if (!devicesByUser.hasOwnProperty(userId)) {
continue;
}
if (!session.sharedWithDevices[userId]) {
session.sharedWithDevices[userId] = {};
}
var devicesToShareWith = devicesByUser[userId];
for (var i = 0; i < devicesToShareWith.length; i++) {
var deviceInfo = devicesToShareWith[i];
session.sharedWithDevices[userId][deviceInfo.deviceId] =
key.chain_index;
}
}
});
};

Expand Down Expand Up @@ -369,20 +369,9 @@ MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
* @param {string=} oldMembership previous membership
*/
MegolmEncryption.prototype.onRoomMembership = function(event, member, oldMembership) {
// if we haven't yet made a session, there's nothing to do here.
if (!this._outboundSession) {
return;
}

var newMembership = member.membership;

if (newMembership === 'join') {
this._onNewRoomMember(member.userId);
return;
}

if (newMembership === 'invite' && oldMembership !== 'join') {
// we don't (yet) share keys with invited members, so nothing to do yet
if (newMembership === 'join' || newMembership === 'invite') {
return;
}

Expand All @@ -396,44 +385,26 @@ MegolmEncryption.prototype.onRoomMembership = function(event, member, oldMembers
};

/**
* handle a new user joining a room
* Get the list of devices for all users in the room
*
* @param {string} userId new member
*/
MegolmEncryption.prototype._onNewRoomMember = function(userId) {
// make sure we have a list of this user's devices. We are happy to use a
// cached version here: we assume that if we already have a list of the
// user's devices, then we already share an e2e room with them, which means
// that they will have announced any new devices via an m.new_device.
this._crypto.downloadKeys([userId], false).done();

// also flag this user up for needing a keyshare.
this._devicesPendingKeyShare[userId] = true;
};


/**
* @inheritdoc
* @param {module:models/room} room
*
* @param {string} userId owner of the device
* @param {string} deviceId deviceId of the device
* @return {module:client.Promise} Promise which resolves to a map
* from userId to deviceId to deviceInfo
*/
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 = {};
}
MegolmEncryption.prototype._getDevicesInRoom = function(room) {
// XXX what about rooms where invitees can see the content?
var roomMembers = utils.map(room.getJoinedMembers(), function(u) {
return u.userId;
});

d[deviceId] = true;
// We are happy to use a cached version here: we assume that if we already
// have a list of the user's devices, then we already share an e2e room
// with them, which means that they will have announced any new devices via
// an m.new_device.
return this._crypto.downloadKeys(roomMembers, false);
};


/**
* Megolm decryption implementation
*
Expand Down