You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-12 08:42:46 +03:00
Embed the sender, recipient, and recipient keys in the plaintext of Olm messages, and check those fields on receipt. Fixes https://github.com/vector-im/vector-web/issues/2483
483 lines
14 KiB
JavaScript
483 lines
14 KiB
JavaScript
/*
|
|
Copyright 2015, 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";
|
|
|
|
/**
|
|
* Defines m.olm encryption/decryption
|
|
*
|
|
* @module crypto/algorithms/megolm
|
|
*/
|
|
|
|
var q = require("q");
|
|
|
|
var utils = require("../../utils");
|
|
var olmlib = require("../olmlib");
|
|
var base = require("./base");
|
|
|
|
/**
|
|
* Megolm encryption implementation
|
|
*
|
|
* @constructor
|
|
* @extends {module:crypto/algorithms/base.EncryptionAlgorithm}
|
|
*
|
|
* @param {object} params parameters, as per
|
|
* {@link module:crypto/algorithms/base.EncryptionAlgorithm}
|
|
*/
|
|
function MegolmEncryption(params) {
|
|
base.EncryptionAlgorithm.call(this, 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);
|
|
|
|
/**
|
|
* @private
|
|
*
|
|
* @param {module:models/room} room
|
|
*
|
|
* @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;
|
|
}
|
|
|
|
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);
|
|
|
|
this._olmDevice.addInboundGroupSession(
|
|
this._roomId, this._olmDevice.deviceCurve25519Key, session_id,
|
|
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 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.
|
|
return this._crypto.downloadKeys(
|
|
roomMembers, false
|
|
).then(function(res) {
|
|
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<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).
|
|
*
|
|
* @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,
|
|
}
|
|
};
|
|
|
|
// 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.
|
|
|
|
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;
|
|
}
|
|
|
|
var devicesToShareWith = shareMap[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;
|
|
}
|
|
|
|
var sessionResult = sessionResults[deviceId];
|
|
if (!sessionResult.sessionId) {
|
|
// no session with this device, probably because there
|
|
// were no one-time keys.
|
|
//
|
|
// we could send them a to_device message anyway, as a
|
|
// signal that they have missed out on the key sharing
|
|
// message because of the lack of keys, but there's not
|
|
// much point in that really; it will mostly serve to clog
|
|
// up to_device inboxes.
|
|
//
|
|
// ensureOlmSessionsForUsers has already done the logging,
|
|
// so just skip it.
|
|
continue;
|
|
}
|
|
|
|
console.log(
|
|
"sharing keys with device " + userId + ":" + deviceId
|
|
);
|
|
|
|
var deviceInfo = sessionResult.device;
|
|
|
|
var encryptedContent = {
|
|
algorithm: olmlib.OLM_ALGORITHM,
|
|
sender_key: self._olmDevice.deviceCurve25519Key,
|
|
ciphertext: {},
|
|
};
|
|
|
|
olmlib.encryptMessageForDevice(
|
|
encryptedContent.ciphertext,
|
|
self._userId,
|
|
self._deviceId,
|
|
self._olmDevice,
|
|
userId,
|
|
deviceInfo,
|
|
payload
|
|
);
|
|
|
|
if (!contentMap[userId]) {
|
|
contentMap[userId] = {};
|
|
}
|
|
|
|
contentMap[userId][deviceId] = encryptedContent;
|
|
haveTargets = true;
|
|
}
|
|
}
|
|
|
|
if (!haveTargets) {
|
|
return q();
|
|
}
|
|
|
|
// TODO: retries
|
|
return self._baseApis.sendToDevice("m.room.encrypted", contentMap);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*
|
|
* @param {module:models/room} room
|
|
* @param {string} eventType
|
|
* @param {object} plaintext event content
|
|
*
|
|
* @return {module:client.Promise} Promise which resolves to the new event body
|
|
*/
|
|
MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
|
|
var self = this;
|
|
return this._ensureOutboundSession(room).then(function(session_id) {
|
|
var payloadJson = {
|
|
room_id: self._roomId,
|
|
type: eventType,
|
|
content: content
|
|
};
|
|
|
|
var ciphertext = self._olmDevice.encryptGroupMessage(
|
|
session_id, JSON.stringify(payloadJson)
|
|
);
|
|
|
|
var encryptedContent = {
|
|
algorithm: olmlib.MEGOLM_ALGORITHM,
|
|
sender_key: self._olmDevice.deviceCurve25519Key,
|
|
ciphertext: ciphertext,
|
|
session_id: session_id,
|
|
// Include our device ID so that recipients can send us a
|
|
// m.new_device message if they don't have our session key.
|
|
device_id: self._deviceId,
|
|
};
|
|
|
|
return encryptedContent;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*
|
|
* @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, oldMembership) {
|
|
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
|
|
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 + " (" + oldMembership +
|
|
"->" + newMembership + ")");
|
|
this._outboundSessionId = null;
|
|
}
|
|
|
|
if (this._prepPromise) {
|
|
console.log("Discarding as-yet-incomplete megolm session due to " +
|
|
"change in membership of " + member.userId + " (" +
|
|
oldMembership + "->" + newMembership + ")");
|
|
this._discardNewSession = true;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* handle a new user joining a 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 {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
|
|
*
|
|
* @constructor
|
|
* @extends {module:crypto/algorithms/base.DecryptionAlgorithm}
|
|
*
|
|
* @param {object} params parameters, as per
|
|
* {@link module:crypto/algorithms/base.DecryptionAlgorithm}
|
|
*/
|
|
function MegolmDecryption(params) {
|
|
base.DecryptionAlgorithm.call(this, params);
|
|
}
|
|
utils.inherits(MegolmDecryption, base.DecryptionAlgorithm);
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*
|
|
* @param {object} event raw event
|
|
*
|
|
* @return {null} The event referred to an unknown megolm session
|
|
* @return {module:crypto.DecryptionResult} decryption result
|
|
*
|
|
* @throws {module:crypto/algorithms/base.DecryptionError} if there is a
|
|
* problem decrypting the event
|
|
*/
|
|
MegolmDecryption.prototype.decryptEvent = function(event) {
|
|
var content = event.content;
|
|
|
|
if (!content.sender_key || !content.session_id ||
|
|
!content.ciphertext
|
|
) {
|
|
throw new base.DecryptionError("Missing fields in input");
|
|
}
|
|
|
|
var res;
|
|
try {
|
|
res = this._olmDevice.decryptGroupMessage(
|
|
event.room_id, content.sender_key, content.session_id, content.ciphertext
|
|
);
|
|
} catch (e) {
|
|
throw new base.DecryptionError(e);
|
|
}
|
|
|
|
if (res === null) {
|
|
return null;
|
|
}
|
|
|
|
var payload = JSON.parse(res.result);
|
|
|
|
// belt-and-braces check that the room id matches that indicated by the HS
|
|
// (this is somewhat redundant, since the megolm session is scoped to the
|
|
// room, so neither the sender nor a MITM can lie about the room_id).
|
|
if (payload.room_id !== event.room_id) {
|
|
throw new base.DecryptionError(
|
|
"Message intended for room " + payload.room_id
|
|
);
|
|
}
|
|
|
|
return {
|
|
payload: payload,
|
|
keysClaimed: res.keysClaimed,
|
|
keysProved: res.keysProved,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*
|
|
* @param {module:models/event.MatrixEvent} event key event
|
|
*/
|
|
MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
|
|
console.log("Adding key from ", event);
|
|
var content = event.getContent();
|
|
|
|
if (!content.room_id ||
|
|
!content.session_id ||
|
|
!content.session_key
|
|
) {
|
|
console.error("key event is missing fields");
|
|
return;
|
|
}
|
|
|
|
this._olmDevice.addInboundGroupSession(
|
|
content.room_id, event.getSenderKey(), content.session_id,
|
|
content.session_key, event.getKeysClaimed()
|
|
);
|
|
};
|
|
|
|
base.registerAlgorithm(
|
|
olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption
|
|
);
|