1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-12-19 10:22:30 +03:00

Merge branch 'release-v0.7.0'

This commit is contained in:
Matthew Hodgson
2016-11-19 01:56:11 +02:00
25 changed files with 1468 additions and 518 deletions

3
.travis.yml Normal file
View File

@@ -0,0 +1,3 @@
language: node_js
node_js:
- node # Latest stable version of nodejs.

View File

@@ -1,3 +1,38 @@
Changes in [0.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.0) (2016-11-18)
================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.4...v0.7.0)
* Avoid a packetstorm of device queries on startup
[\#297](https://github.com/matrix-org/matrix-js-sdk/pull/297)
* E2E: Check devices to share keys with on each send
[\#295](https://github.com/matrix-org/matrix-js-sdk/pull/295)
* Apply unknown-keyshare mitigations
[\#296](https://github.com/matrix-org/matrix-js-sdk/pull/296)
* distinguish unknown users from deviceless users
[\#294](https://github.com/matrix-org/matrix-js-sdk/pull/294)
* Allow starting client with initialSyncLimit = 0
[\#293](https://github.com/matrix-org/matrix-js-sdk/pull/293)
* Make timeline-window _unpaginate public and rename to unpaginate
[\#289](https://github.com/matrix-org/matrix-js-sdk/pull/289)
* Send a STOPPED sync updated after call to stopClient
[\#286](https://github.com/matrix-org/matrix-js-sdk/pull/286)
* Fix bug in verifying megolm event senders
[\#292](https://github.com/matrix-org/matrix-js-sdk/pull/292)
* Handle decryption of events after they arrive
[\#288](https://github.com/matrix-org/matrix-js-sdk/pull/288)
* Fix examples.
[\#287](https://github.com/matrix-org/matrix-js-sdk/pull/287)
* Add a travis.yml
[\#278](https://github.com/matrix-org/matrix-js-sdk/pull/278)
* Encrypt all events, including 'm.call.*'
[\#277](https://github.com/matrix-org/matrix-js-sdk/pull/277)
* Ignore reshares of known megolm sessions
[\#276](https://github.com/matrix-org/matrix-js-sdk/pull/276)
* Log to the console on unknown session
[\#274](https://github.com/matrix-org/matrix-js-sdk/pull/274)
* Make it easier for SDK users to wrap prevailing the 'request' function
[\#273](https://github.com/matrix-org/matrix-js-sdk/pull/273)
Changes in [0.6.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.6.4) (2016-11-04) Changes in [0.6.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.6.4) (2016-11-04)
================================================================================================ ================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.4-rc.2...v0.6.4) [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.4-rc.2...v0.6.4)

View File

@@ -1 +1 @@
../../../dist/browser-matrix-dev.js ../../../dist/browser-matrix.js

View File

@@ -135,11 +135,15 @@ rl.on('line', function(line) {
// ==== END User input // ==== END User input
// show the room list after syncing. // show the room list after syncing.
matrixClient.on("syncComplete", function() { matrixClient.on("sync", function(state, prevState, data) {
switch (state) {
case "PREPARED":
setRoomList(); setRoomList();
printRoomList(); printRoomList();
printHelp(); printHelp();
rl.prompt(); rl.prompt();
break;
}
}); });
matrixClient.on("Room", function() { matrixClient.on("Room", function() {

View File

@@ -44,7 +44,15 @@ window.onload = function() {
disableButtons(true, true, true); disableButtons(true, true, true);
}; };
client.on("syncComplete", function () { matrixClient.on("sync", function(state, prevState, data) {
switch (state) {
case "PREPARED":
syncComplete();
break;
}
});
function syncComplete() {
document.getElementById("result").innerHTML = "<p>Ready for calls.</p>"; document.getElementById("result").innerHTML = "<p>Ready for calls.</p>";
disableButtons(false, true, true); disableButtons(false, true, true);
@@ -85,5 +93,5 @@ client.on("syncComplete", function () {
call = c; call = c;
addListeners(call); addListeners(call);
}); });
}); }
client.startClient(); client.startClient();

View File

@@ -1 +1 @@
../../../dist/browser-matrix-dev.js ../../../dist/browser-matrix.js

View File

@@ -351,7 +351,7 @@ MatrixClient.prototype.getStoredDevicesForUser = function(userId) {
if (this._crypto === null) { if (this._crypto === null) {
throw new Error("End-to-end encryption disabled"); throw new Error("End-to-end encryption disabled");
} }
return this._crypto.getStoredDevicesForUser(userId); return this._crypto.getStoredDevicesForUser(userId) || [];
}; };
@@ -463,38 +463,31 @@ MatrixClient.prototype.isRoomEncrypted = function(roomId) {
* Decrypt a received event according to the algorithm specified in the event. * Decrypt a received event according to the algorithm specified in the event.
* *
* @param {MatrixClient} client * @param {MatrixClient} client
* @param {object} raw event * @param {MatrixEvent} event
*
* @return {MatrixEvent}
*/ */
function _decryptEvent(client, event) { function _decryptEvent(client, event) {
if (!client._crypto) { if (!client._crypto) {
return _badEncryptedMessage(event, "**Encryption not enabled**"); _badEncryptedMessage(event, "**Encryption not enabled**");
return;
} }
var decryptionResult;
try { try {
decryptionResult = client._crypto.decryptEvent(event); client._crypto.decryptEvent(event);
} catch (e) { } catch (e) {
if (!(e instanceof Crypto.DecryptionError)) { if (!(e instanceof Crypto.DecryptionError)) {
throw e; throw e;
} }
return _badEncryptedMessage(event, "**" + e.message + "**"); _badEncryptedMessage(event, "**" + e.message + "**");
return;
} }
return new MatrixEvent(
event, decryptionResult.payload,
decryptionResult.keysProved,
decryptionResult.keysClaimed
);
} }
function _badEncryptedMessage(event, reason) { function _badEncryptedMessage(event, reason) {
return new MatrixEvent(event, { event.setClearData({
type: "m.room.message", type: "m.room.message",
content: { content: {
msgtype: "m.bad.encrypted", msgtype: "m.bad.encrypted",
body: reason, body: reason,
content: event.content,
}, },
}); });
} }
@@ -2829,10 +2822,11 @@ function _resolve(callback, defer, res) {
function _PojoToMatrixEventMapper(client) { function _PojoToMatrixEventMapper(client) {
function mapper(plainOldJsObject) { function mapper(plainOldJsObject) {
if (plainOldJsObject.type === "m.room.encrypted") { var event = new MatrixEvent(plainOldJsObject);
return _decryptEvent(client, plainOldJsObject); if (event.isEncrypted()) {
_decryptEvent(client, event);
} }
return new MatrixEvent(plainOldJsObject); return event;
} }
return mapper; return mapper;
} }
@@ -2872,6 +2866,10 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
/** /**
* Fires whenever the SDK receives a new event. * Fires whenever the SDK receives a new event.
* <p>
* This is only fired for live events received via /sync - it is not fired for
* events received over context, search, or pagination APIs.
*
* @event module:client~MatrixClient#"event" * @event module:client~MatrixClient#"event"
* @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {MatrixEvent} event The matrix event which caused this event to fire.
* @example * @example

View File

@@ -627,6 +627,24 @@ OlmDevice.prototype.addInboundGroupSession = function(
roomId, senderKey, sessionId, sessionKey, keysClaimed roomId, senderKey, sessionId, sessionKey, keysClaimed
) { ) {
var self = this; var self = this;
/* if we already have this session, consider updating it */
function updateSession(session) {
console.log("Update for megolm session " + senderKey + "/" + sessionId);
// for now we just ignore updates. TODO: implement something here
return true;
}
var r = this._getInboundGroupSession(
roomId, senderKey, sessionId, updateSession
);
if (r !== null) {
return;
}
// new session.
var session = new Olm.InboundGroupSession(); var session = new Olm.InboundGroupSession();
try { try {
session.create(sessionKey); session.create(sessionKey);

View File

@@ -88,15 +88,6 @@ EncryptionAlgorithm.prototype.onRoomMembership = function(
event, member, oldMembership 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 * base type for decryption implementations
* *
@@ -105,11 +96,16 @@ EncryptionAlgorithm.prototype.onNewDevice = function(userId, deviceId) {};
* *
* @param {object} params parameters * @param {object} params parameters
* @param {string} params.userId The UserID for the local user * @param {string} params.userId The UserID for the local user
* @param {module:crypto} params.crypto crypto core
* @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper * @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper
* @param {string=} params.roomId The ID of the room we will be receiving
* from. Null for to-device events.
*/ */
var DecryptionAlgorithm = function(params) { var DecryptionAlgorithm = function(params) {
this._userId = params.userId; this._userId = params.userId;
this._crypto = params.crypto;
this._olmDevice = params.olmDevice; this._olmDevice = params.olmDevice;
this._roomId = params.roomId;
}; };
/** */ /** */
module.exports.DecryptionAlgorithm = DecryptionAlgorithm; module.exports.DecryptionAlgorithm = DecryptionAlgorithm;

View File

@@ -38,12 +38,17 @@ var base = require("./base");
* @property {Number} creationTime when the session was created (ms since the epoch) * @property {Number} creationTime when the session was created (ms since the epoch)
* @property {module:client.Promise?} sharePromise If a share operation is in progress, * @property {module:client.Promise?} sharePromise If a share operation is in progress,
* a promise which resolves when it is complete. * 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) { function OutboundSessionInfo(sessionId) {
this.sessionId = sessionId; this.sessionId = sessionId;
this.useCount = 0; this.useCount = 0;
this.creationTime = new Date().getTime(); this.creationTime = new Date().getTime();
this.sharePromise = null; this.sharePromise = null;
this.sharedWithDevices = {};
} }
@@ -90,11 +95,6 @@ function MegolmEncryption(params) {
// case _outboundSession.sharePromise will be non-null.) // case _outboundSession.sharePromise will be non-null.)
this._outboundSession = 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 // default rotation periods
this._sessionRotationPeriodMsgs = 100; this._sessionRotationPeriodMsgs = 100;
this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000; this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000;
@@ -134,32 +134,55 @@ MegolmEncryption.prototype._ensureOutboundSession = function(room) {
return session.sharePromise; return session.sharePromise;
} }
// no share in progress: check for new devices // no share in progress: check if we need to share with any devices
var shareMap = this._devicesPendingKeyShare; var prom = this._getDevicesInRoom(room).then(function(devicesInRoom) {
this._devicesPendingKeyShare = {}; var shareMap = {};
// check each user is (still) a member of the room for (var userId in devicesInRoom) {
for (var userId in shareMap) { if (!devicesInRoom.hasOwnProperty(userId)) {
if (!shareMap.hasOwnProperty(userId)) {
continue; continue;
} }
// XXX what about rooms where invitees can see the content? var userDevices = devicesInRoom[userId];
var member = room.getMember(userId);
if (member.membership !== "join") { for (var deviceId in userDevices) {
delete shareMap[userId]; if (!userDevices.hasOwnProperty(deviceId)) {
continue;
}
var deviceInfo = userDevices[deviceId];
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( return self._shareKeyWithDevices(
session.sessionId, shareMap session, shareMap
).finally(function() { );
}).finally(function() {
session.sharePromise = null; session.sharePromise = null;
}).then(function() { }).then(function() {
return session; return session;
}); });
return session.sharePromise; session.sharePromise = prom;
return prom;
}; };
/** /**
@@ -178,95 +201,53 @@ MegolmEncryption.prototype._prepareNewSession = function(room) {
key.key, {ed25519: this._olmDevice.deviceEd25519Key} key.key, {ed25519: this._olmDevice.deviceEd25519Key}
); );
// we're going to share the key with all current members of the room, return new OutboundSessionInfo(session_id);
// 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;
}; };
/** /**
* @private * @private
* *
* @param {string} session_id * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session
* *
* @param {Object<string, Object<string, boolean>|boolean>} shareMap * @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
* Map from userid to either: true (meaning this is a new user in the room, * map from userid to list of devices
* 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 * @return {module:client.Promise} Promise which resolves once the key sharing
* message has been sent. * message has been sent.
*/ */
MegolmEncryption.prototype._shareKeyWithDevices = function(session_id, shareMap) { MegolmEncryption.prototype._shareKeyWithDevices = function(session, devicesByUser) {
var self = this; var self = this;
var key = this._olmDevice.getOutboundGroupSessionKey(session_id); var key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId);
var payload = { var payload = {
type: "m.room_key", type: "m.room_key",
content: { content: {
algorithm: olmlib.MEGOLM_ALGORITHM, algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: this._roomId, room_id: this._roomId,
session_id: session_id, session_id: session.sessionId,
session_key: key.key, session_key: key.key,
chain_index: key.chain_index, 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 contentMap = {};
return olmlib.ensureOlmSessionsForDevices(
this._olmDevice, this._baseApis, devicesByUser
).then(function(devicemap) {
var haveTargets = false; var haveTargets = false;
for (var userId in devicemap) { for (var userId in devicesByUser) {
if (!devicemap.hasOwnProperty(userId)) { if (!devicesByUser.hasOwnProperty(userId)) {
continue; continue;
} }
var devicesToShareWith = shareMap[userId]; var devicesToShareWith = devicesByUser[userId];
var sessionResults = devicemap[userId]; var sessionResults = devicemap[userId];
for (var deviceId in sessionResults) { for (var i = 0; i < devicesToShareWith.length; i++) {
if (!sessionResults.hasOwnProperty(deviceId)) { var deviceInfo = devicesToShareWith[i];
continue; var deviceId = deviceInfo.deviceId;
}
if (devicesToShareWith === true) {
// all devices
} else if (!devicesToShareWith[deviceId]) {
// not a new device
continue;
}
var sessionResult = sessionResults[deviceId]; var sessionResult = sessionResults[deviceId];
if (!sessionResult.sessionId) { if (!sessionResult.sessionId) {
@@ -288,8 +269,6 @@ MegolmEncryption.prototype._shareKeyWithDevices = function(session_id, shareMap)
"sharing keys with device " + userId + ":" + deviceId "sharing keys with device " + userId + ":" + deviceId
); );
var deviceInfo = sessionResult.device;
var encryptedContent = { var encryptedContent = {
algorithm: olmlib.OLM_ALGORITHM, algorithm: olmlib.OLM_ALGORITHM,
sender_key: self._olmDevice.deviceCurve25519Key, sender_key: self._olmDevice.deviceCurve25519Key,
@@ -321,6 +300,27 @@ MegolmEncryption.prototype._shareKeyWithDevices = function(session_id, shareMap)
// TODO: retries // TODO: retries
return self._baseApis.sendToDevice("m.room.encrypted", contentMap); 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;
}
}
}); });
}; };
@@ -369,20 +369,9 @@ MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
* @param {string=} oldMembership previous membership * @param {string=} oldMembership previous membership
*/ */
MegolmEncryption.prototype.onRoomMembership = function(event, member, oldMembership) { 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; var newMembership = member.membership;
if (newMembership === 'join') { if (newMembership === 'join' || newMembership === 'invite') {
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; return;
} }
@@ -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 * @param {module:models/room} room
*/
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 * @return {module:client.Promise} Promise which resolves to a map
* @param {string} deviceId deviceId of the device * from userId to deviceId to deviceInfo
*/ */
MegolmEncryption.prototype.onNewDevice = function(userId, deviceId) { MegolmEncryption.prototype._getDevicesInRoom = function(room) {
var d = this._devicesPendingKeyShare[userId]; // XXX what about rooms where invitees can see the content?
var roomMembers = utils.map(room.getJoinedMembers(), function(u) {
return u.userId;
});
if (d === true) { // We are happy to use a cached version here: we assume that if we already
// we already want to share keys with all devices for this user // have a list of the user's devices, then we already share an e2e room
return; // with them, which means that they will have announced any new devices via
} // an m.new_device.
return this._crypto.downloadKeys(roomMembers, false);
if (!d) {
this._devicesPendingKeyShare[userId] = d = {};
}
d[deviceId] = true;
}; };
/** /**
* Megolm decryption implementation * Megolm decryption implementation
* *
@@ -445,13 +416,17 @@ MegolmEncryption.prototype.onNewDevice = function(userId, deviceId) {
*/ */
function MegolmDecryption(params) { function MegolmDecryption(params) {
base.DecryptionAlgorithm.call(this, params); base.DecryptionAlgorithm.call(this, params);
// events which we couldn't decrypt due to unknown sessions / indexes: map from
// senderKey|sessionId to list of MatrixEvents
this._pendingEvents = {};
} }
utils.inherits(MegolmDecryption, base.DecryptionAlgorithm); utils.inherits(MegolmDecryption, base.DecryptionAlgorithm);
/** /**
* @inheritdoc * @inheritdoc
* *
* @param {object} event raw event * @param {MatrixEvent} event
* *
* @return {null} The event referred to an unknown megolm session * @return {null} The event referred to an unknown megolm session
* @return {module:crypto.DecryptionResult} decryption result * @return {module:crypto.DecryptionResult} decryption result
@@ -460,7 +435,7 @@ utils.inherits(MegolmDecryption, base.DecryptionAlgorithm);
* problem decrypting the event * problem decrypting the event
*/ */
MegolmDecryption.prototype.decryptEvent = function(event) { MegolmDecryption.prototype.decryptEvent = function(event) {
var content = event.content; var content = event.getWireContent();
if (!content.sender_key || !content.session_id || if (!content.sender_key || !content.session_id ||
!content.ciphertext !content.ciphertext
@@ -471,14 +446,19 @@ MegolmDecryption.prototype.decryptEvent = function(event) {
var res; var res;
try { try {
res = this._olmDevice.decryptGroupMessage( res = this._olmDevice.decryptGroupMessage(
event.room_id, content.sender_key, content.session_id, content.ciphertext event.getRoomId(), content.sender_key, content.session_id, content.ciphertext
); );
} catch (e) { } catch (e) {
if (e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') {
this._addEventToPendingList(event);
}
throw new base.DecryptionError(e); throw new base.DecryptionError(e);
} }
if (res === null) { if (res === null) {
return null; // We've got a message for a session we don't have.
this._addEventToPendingList(event);
throw new base.DecryptionError("Unknown inbound session id");
} }
var payload = JSON.parse(res.result); var payload = JSON.parse(res.result);
@@ -486,17 +466,31 @@ MegolmDecryption.prototype.decryptEvent = function(event) {
// belt-and-braces check that the room id matches that indicated by the HS // 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 // (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). // room, so neither the sender nor a MITM can lie about the room_id).
if (payload.room_id !== event.room_id) { if (payload.room_id !== event.getRoomId()) {
throw new base.DecryptionError( throw new base.DecryptionError(
"Message intended for room " + payload.room_id "Message intended for room " + payload.room_id
); );
} }
return { event.setClearData(payload, res.keysProved, res.keysClaimed);
payload: payload,
keysClaimed: res.keysClaimed,
keysProved: res.keysProved,
}; };
/**
* Add an event to the list of those we couldn't decrypt the first time we
* saw them.
*
* @private
*
* @param {module:models/event.MatrixEvent} event
*/
MegolmDecryption.prototype._addEventToPendingList = function(event) {
var content = event.getWireContent();
var k = content.sender_key + "|" + content.session_id;
if (!this._pendingEvents[k]) {
this._pendingEvents[k] = [];
}
this._pendingEvents[k].push(event);
}; };
/** /**
@@ -520,6 +514,22 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
content.room_id, event.getSenderKey(), content.session_id, content.room_id, event.getSenderKey(), content.session_id,
content.session_key, event.getKeysClaimed() content.session_key, event.getKeysClaimed()
); );
var k = event.getSenderKey() + "|" + content.session_id;
var pending = this._pendingEvents[k];
if (pending) {
// have another go at decrypting events sent with this session.
delete this._pendingEvents[k];
for (var i = 0; i < pending.length; i++) {
try {
this.decryptEvent(pending[i]);
console.log("successful re-decryption of", pending[i]);
} catch (e) {
console.log("Still can't decrypt", pending[i], e.stack || e);
}
}
}
}; };
base.registerAlgorithm( base.registerAlgorithm(

View File

@@ -151,15 +151,13 @@ utils.inherits(OlmDecryption, base.DecryptionAlgorithm);
/** /**
* @inheritdoc * @inheritdoc
* *
* @param {object} event raw event * @param {MatrixEvent} event
*
* @return {module:crypto.DecryptionResult} decryption result
* *
* @throws {module:crypto/algorithms/base.DecryptionError} if there is a * @throws {module:crypto/algorithms/base.DecryptionError} if there is a
* problem decrypting the event * problem decrypting the event
*/ */
OlmDecryption.prototype.decryptEvent = function(event) { OlmDecryption.prototype.decryptEvent = function(event) {
var content = event.content; var content = event.getWireContent();
var deviceKey = content.sender_key; var deviceKey = content.sender_key;
var ciphertext = content.ciphertext; var ciphertext = content.ciphertext;
@@ -178,7 +176,7 @@ OlmDecryption.prototype.decryptEvent = function(event) {
} catch (e) { } catch (e) {
console.warn( console.warn(
"Failed to decrypt Olm event (id=" + "Failed to decrypt Olm event (id=" +
event.event_id + ") from " + deviceKey + event.getId() + ") from " + deviceKey +
": " + e.message ": " + e.message
); );
throw new base.DecryptionError("Bad Encrypted Message"); throw new base.DecryptionError("Bad Encrypted Message");
@@ -188,15 +186,9 @@ OlmDecryption.prototype.decryptEvent = function(event) {
// check that we were the intended recipient, to avoid unknown-key attack // check that we were the intended recipient, to avoid unknown-key attack
// https://github.com/vector-im/vector-web/issues/2483 // https://github.com/vector-im/vector-web/issues/2483
if (payload.recipient === undefined) { if (payload.recipient != this._userId) {
// older versions of riot did not set this field, so we cannot make
// this check. TODO: kill this off once our users have updated
console.warn( console.warn(
"Olm event (id=" + event.event_id + ") contains no 'recipient' " + "Event " + event.getId() + ": Intended recipient " +
"property; cannot prevent unknown-key attack");
} else if (payload.recipient != this._userId) {
console.warn(
"Event " + event.event_id + ": Intended recipient " +
payload.recipient + " does not match our id " + this._userId payload.recipient + " does not match our id " + this._userId
); );
throw new base.DecryptionError( throw new base.DecryptionError(
@@ -204,15 +196,10 @@ OlmDecryption.prototype.decryptEvent = function(event) {
); );
} }
if (payload.recipient_keys === undefined) { if (payload.recipient_keys.ed25519 !=
// ditto
console.warn(
"Olm event (id=" + event.event_id + ") contains no " +
"'recipient_keys' property; cannot prevent unknown-key attack");
} else if (payload.recipient_keys.ed25519 !=
this._olmDevice.deviceEd25519Key) { this._olmDevice.deviceEd25519Key) {
console.warn( console.warn(
"Event " + event.event_id + ": Intended recipient ed25519 key " + "Event " + event.getId() + ": Intended recipient ed25519 key " +
payload.recipient_keys.ed25519 + " did not match ours" payload.recipient_keys.ed25519 + " did not match ours"
); );
throw new base.DecryptionError("Message not intended for this device"); throw new base.DecryptionError("Message not intended for this device");
@@ -222,15 +209,10 @@ OlmDecryption.prototype.decryptEvent = function(event) {
// avoid people masquerading as others. // avoid people masquerading as others.
// (this check is also provided via the sender's embedded ed25519 key, // (this check is also provided via the sender's embedded ed25519 key,
// which is checked elsewhere). // which is checked elsewhere).
if (payload.sender === undefined) { if (payload.sender != event.getSender()) {
// ditto
console.warn( console.warn(
"Olm event (id=" + event.event_id + ") contains no " + "Event " + event.getId() + ": original sender " + payload.sender +
"'sender' property; cannot prevent unknown-key attack"); " does not match reported sender " + event.getSender()
} else if (payload.sender != event.sender) {
console.warn(
"Event " + event.event_id + ": original sender " + payload.sender +
" does not match reported sender " + event.sender
); );
throw new base.DecryptionError( throw new base.DecryptionError(
"Message forwarded from " + payload.sender "Message forwarded from " + payload.sender
@@ -238,9 +220,9 @@ OlmDecryption.prototype.decryptEvent = function(event) {
} }
// Olm events intended for a room have a room_id. // Olm events intended for a room have a room_id.
if (payload.room_id !== event.room_id) { if (payload.room_id !== event.getRoomId()) {
console.warn( console.warn(
"Event " + event.event_id + ": original room " + payload.room_id + "Event " + event.getId() + ": original room " + payload.room_id +
" does not match reported room " + event.room_id " does not match reported room " + event.room_id
); );
throw new base.DecryptionError( throw new base.DecryptionError(
@@ -248,12 +230,7 @@ OlmDecryption.prototype.decryptEvent = function(event) {
); );
} }
return { event.setClearData(payload, {curve25519: deviceKey}, payload.keys || {});
payload: payload,
sessionExists: true,
keysProved: {curve25519: deviceKey},
keysClaimed: payload.keys || {}
};
}; };

View File

@@ -54,10 +54,17 @@ function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId) {
this._userId = userId; this._userId = userId;
this._deviceId = deviceId; this._deviceId = deviceId;
this._initialSyncCompleted = false;
// userId -> deviceId -> true
this._pendingNewDevices = {};
this._olmDevice = new OlmDevice(sessionStore); this._olmDevice = new OlmDevice(sessionStore);
// EncryptionAlgorithm instance for each room // EncryptionAlgorithm instance for each room
this._roomAlgorithms = {}; this._roomEncryptors = {};
// map from algorithm to DecryptionAlgorithm instance, for each room
this._roomDecryptors = {};
this._supportedAlgorithms = utils.keys( this._supportedAlgorithms = utils.keys(
algorithms.DECRYPTION_CLASSES algorithms.DECRYPTION_CLASSES
@@ -269,22 +276,26 @@ Crypto.prototype.downloadKeys = function(userIds, forceDownload) {
// map from userid -> deviceid -> DeviceInfo // map from userid -> deviceid -> DeviceInfo
var stored = {}; var stored = {};
function storeDev(userId, dev) {
stored[userId][dev.deviceId] = dev;
}
// list of userids we need to download keys for // list of userids we need to download keys for
var downloadUsers = []; var downloadUsers = [];
if (forceDownload) {
downloadUsers = userIds;
} else {
for (var i = 0; i < userIds.length; ++i) { for (var i = 0; i < userIds.length; ++i) {
var userId = userIds[i]; var userId = userIds[i];
stored[userId] = {};
var devices = this.getStoredDevicesForUser(userId); var devices = this.getStoredDevicesForUser(userId);
for (var j = 0; j < devices.length; ++j) {
var dev = devices[j];
stored[userId][dev.deviceId] = dev;
}
if (devices.length === 0 || forceDownload) { if (!devices) {
downloadUsers.push(userId); downloadUsers.push(userId);
} else {
stored[userId] = {};
devices.map(storeDev.bind(null, userId));
}
} }
} }
@@ -292,28 +303,79 @@ Crypto.prototype.downloadKeys = function(userIds, forceDownload) {
return q(stored); return q(stored);
} }
return this._baseApis.downloadKeysForUsers( var r = this._doKeyDownloadForUsers(downloadUsers);
var promises = [];
downloadUsers.map(function(u) {
promises.push(r[u].catch(function(e) {
console.warn('Error downloading keys for user ' + u + ':', e);
}).then(function() {
stored[u] = {};
var devices = self.getStoredDevicesForUser(u) || [];
devices.map(storeDev.bind(null, u));
}));
});
return q.all(promises).then(function() {
return stored;
});
};
/**
* @param {string[]} downloadUsers list of userIds
*
* @return {Object a map from userId to a promise for a result for that user
*/
Crypto.prototype._doKeyDownloadForUsers = function(downloadUsers) {
var self = this;
console.log('Starting key download for ' + downloadUsers);
var deferMap = {};
var promiseMap = {};
downloadUsers.map(function(u) {
deferMap[u] = q.defer();
promiseMap[u] = deferMap[u].promise;
});
this._baseApis.downloadKeysForUsers(
downloadUsers downloadUsers
).then(function(res) { ).done(function(res) {
for (var userId in res.device_keys) { var dk = res.device_keys || {};
if (!stored.hasOwnProperty(userId)) {
// spurious result for (var i = 0; i < downloadUsers.length; ++i) {
var userId = downloadUsers[i];
var deviceId;
console.log('got keys for ' + userId + ':', dk[userId]);
if (!dk[userId]) {
// no result for this user
var err = 'Unknown';
// TODO: do something with res.failures
deferMap[userId].reject(err);
continue; continue;
} }
// map from deviceid -> deviceinfo for this user // map from deviceid -> deviceinfo for this user
var userStore = stored[userId]; var userStore = {};
var updated = _updateStoredDeviceKeysForUser( var devs = self._sessionStore.getEndToEndDevicesForUser(userId);
self._olmDevice, userId, userStore, res.device_keys[userId] if (devs) {
); for (deviceId in devs) {
if (devs.hasOwnProperty(deviceId)) {
if (!updated) { var d = DeviceInfo.fromStorage(devs[deviceId], deviceId);
continue; userStore[deviceId] = d;
} }
}
}
_updateStoredDeviceKeysForUser(
self._olmDevice, userId, userStore, dk[userId]
);
// update the session store // update the session store
var storage = {}; var storage = {};
for (var deviceId in userStore) { for (deviceId in userStore) {
if (!userStore.hasOwnProperty(deviceId)) { if (!userStore.hasOwnProperty(deviceId)) {
continue; continue;
} }
@@ -323,9 +385,16 @@ Crypto.prototype.downloadKeys = function(userIds, forceDownload) {
self._sessionStore.storeEndToEndDevicesForUser( self._sessionStore.storeEndToEndDevicesForUser(
userId, storage userId, storage
); );
deferMap[userId].resolve();
} }
return stored; }, function(err) {
downloadUsers.map(function(u) {
deferMap[u].reject(err);
}); });
});
return promiseMap;
}; };
function _updateStoredDeviceKeysForUser(_olmDevice, userId, userStore, function _updateStoredDeviceKeysForUser(_olmDevice, userId, userStore,
@@ -399,7 +468,7 @@ function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
var unsigned = deviceResult.unsigned || {}; var unsigned = deviceResult.unsigned || {};
try { try {
_verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey); olmlib.verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey);
} catch (e) { } catch (e) {
console.log("Unable to verify signature on device " + console.log("Unable to verify signature on device " +
userId + ":" + deviceId + ":", e); userId + ":" + deviceId + ":", e);
@@ -437,12 +506,13 @@ function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
* *
* @param {string} userId the user to list keys for. * @param {string} userId the user to list keys for.
* *
* @return {module:crypto/deviceinfo[]} list of devices * @return {module:crypto/deviceinfo[]?} list of devices, or null if we haven't
* managed to get a list of devices for this user yet.
*/ */
Crypto.prototype.getStoredDevicesForUser = function(userId) { Crypto.prototype.getStoredDevicesForUser = function(userId) {
var devs = this._sessionStore.getEndToEndDevicesForUser(userId); var devs = this._sessionStore.getEndToEndDevicesForUser(userId);
if (!devs) { if (!devs) {
return []; return null;
} }
var res = []; var res = [];
for (var deviceId in devs) { for (var deviceId in devs) {
@@ -453,6 +523,22 @@ Crypto.prototype.getStoredDevicesForUser = function(userId) {
return res; return res;
}; };
/**
* Get the stored keys for a single device
*
* @param {string} userId
* @param {string} deviceId
*
* @return {module:crypto/deviceinfo?} list of devices, or undefined
* if we don't know about this device
*/
Crypto.prototype.getStoredDevice = function(userId, deviceId) {
var devs = this._sessionStore.getEndToEndDevicesForUser(userId);
if (!devs || !devs[deviceId]) {
return undefined;
}
return DeviceInfo.fromStorage(devs[deviceId], deviceId);
};
/** /**
* List the stored device keys for a user id * List the stored device keys for a user id
@@ -465,7 +551,7 @@ Crypto.prototype.getStoredDevicesForUser = function(userId) {
* "key", and "display_name" parameters. * "key", and "display_name" parameters.
*/ */
Crypto.prototype.listDeviceKeys = function(userId) { Crypto.prototype.listDeviceKeys = function(userId) {
var devices = this.getStoredDevicesForUser(userId); var devices = this.getStoredDevicesForUser(userId) || [];
var result = []; var result = [];
@@ -597,7 +683,7 @@ Crypto.prototype.setDeviceVerification = function(userId, deviceId, verified, bl
* @return {Object.<string, {deviceIdKey: string, sessions: object[]}>} * @return {Object.<string, {deviceIdKey: string, sessions: object[]}>}
*/ */
Crypto.prototype.getOlmSessionsForUser = function(userId) { Crypto.prototype.getOlmSessionsForUser = function(userId) {
var devices = this.getStoredDevicesForUser(userId); var devices = this.getStoredDevicesForUser(userId) || [];
var result = {}; var result = {};
for (var j = 0; j < devices.length; ++j) { for (var j = 0; j < devices.length; ++j) {
var device = devices[j]; var device = devices[j];
@@ -705,7 +791,7 @@ Crypto.prototype.setRoomEncryption = function(roomId, config) {
roomId: roomId, roomId: roomId,
config: config, config: config,
}); });
this._roomAlgorithms[roomId] = alg; this._roomEncryptors[roomId] = alg;
}; };
@@ -717,7 +803,8 @@ Crypto.prototype.setRoomEncryption = function(roomId, config) {
*/ */
/** /**
* Try to make sure we have established olm sessions for the given users. * Try to make sure we have established olm sessions for all known devices for
* the given users.
* *
* @param {string[]} users list of user ids * @param {string[]} users list of user ids
* *
@@ -726,19 +813,15 @@ Crypto.prototype.setRoomEncryption = function(roomId, config) {
* {@link module:crypto~OlmSessionResult} * {@link module:crypto~OlmSessionResult}
*/ */
Crypto.prototype.ensureOlmSessionsForUsers = function(users) { Crypto.prototype.ensureOlmSessionsForUsers = function(users) {
var devicesWithoutSession = [ var devicesByUser = {};
// [userId, deviceId, deviceInfo], ...
];
var result = {};
for (var i = 0; i < users.length; ++i) { for (var i = 0; i < users.length; ++i) {
var userId = users[i]; var userId = users[i];
result[userId] = {}; devicesByUser[userId] = [];
var devices = this.getStoredDevicesForUser(userId); var devices = this.getStoredDevicesForUser(userId) || [];
for (var j = 0; j < devices.length; ++j) { for (var j = 0; j < devices.length; ++j) {
var deviceInfo = devices[j]; var deviceInfo = devices[j];
var deviceId = deviceInfo.deviceId;
var key = deviceInfo.getIdentityKey(); var key = deviceInfo.getIdentityKey();
if (key == this._olmDevice.deviceCurve25519Key) { if (key == this._olmDevice.deviceCurve25519Key) {
@@ -750,87 +833,13 @@ Crypto.prototype.ensureOlmSessionsForUsers = function(users) {
continue; continue;
} }
var sessionId = this._olmDevice.getSessionIdForDevice(key); devicesByUser[userId].push(deviceInfo);
if (sessionId === null) {
devicesWithoutSession.push([userId, deviceId, deviceInfo]);
}
result[userId][deviceId] = {
device: deviceInfo,
sessionId: sessionId,
};
} }
} }
if (devicesWithoutSession.length === 0) { return olmlib.ensureOlmSessionsForDevices(
return q(result); this._olmDevice, this._baseApis, devicesByUser
}
// TODO: this has a race condition - if we try to send another message
// while we are claiming a key, we will end up claiming two and setting up
// two sessions.
//
// That should eventually resolve itself, but it's poor form.
var self = this;
var oneTimeKeyAlgorithm = "signed_curve25519";
return this._baseApis.claimOneTimeKeys(
devicesWithoutSession, oneTimeKeyAlgorithm
).then(function(res) {
for (var i = 0; i < devicesWithoutSession.length; ++i) {
var device = devicesWithoutSession[i];
var userId = device[0];
var deviceId = device[1];
var deviceInfo = device[2];
var userRes = res.one_time_keys[userId] || {};
var deviceRes = userRes[deviceId];
var oneTimeKey = null;
for (var keyId in deviceRes) {
if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) {
oneTimeKey = deviceRes[keyId];
}
}
if (!oneTimeKey) {
console.warn(
"No one-time keys (alg=" + oneTimeKeyAlgorithm +
") for device " + userId + ":" + deviceId
); );
continue;
}
try {
_verifySignature(
self._olmDevice, oneTimeKey, userId, deviceId,
deviceInfo.getFingerprint()
);
} catch (e) {
console.log(
"Unable to verify signature on one-time key for device " +
userId + ":" + deviceId + ":", e
);
continue;
}
var sid;
try {
sid = self._olmDevice.createOutboundSession(
deviceInfo.getIdentityKey(), oneTimeKey.key
);
} catch (e) {
// possibly a bad key
console.error("Error starting session with device " +
userId + ":" + deviceId + ": " + e);
continue;
}
console.log("Started new sessionid " + sid +
" for device " + userId + ":" + deviceId);
result[userId][deviceId].sessionId = sid;
}
return result;
});
}; };
/** /**
@@ -839,10 +848,9 @@ Crypto.prototype.ensureOlmSessionsForUsers = function(users) {
* @return {bool} whether encryption is enabled. * @return {bool} whether encryption is enabled.
*/ */
Crypto.prototype.isRoomEncrypted = function(roomId) { Crypto.prototype.isRoomEncrypted = function(roomId) {
return Boolean(this._roomAlgorithms[roomId]); return Boolean(this._roomEncryptors[roomId]);
}; };
/** /**
* Encrypt an event according to the configuration of the room, if necessary. * Encrypt an event according to the configuration of the room, if necessary.
* *
@@ -862,18 +870,13 @@ Crypto.prototype.encryptEventIfNeeded = function(event, room) {
return null; return null;
} }
if (event.getType() !== "m.room.message") {
// we only encrypt m.room.message
return null;
}
if (!room) { if (!room) {
throw new Error("Cannot send encrypted messages in unknown rooms"); throw new Error("Cannot send encrypted messages in unknown rooms");
} }
var roomId = event.getRoomId(); var roomId = event.getRoomId();
var alg = this._roomAlgorithms[roomId]; var alg = this._roomEncryptors[roomId];
if (!alg) { if (!alg) {
// not encrypting messages in this room // not encrypting messages in this room
@@ -903,66 +906,17 @@ Crypto.prototype.encryptEventIfNeeded = function(event, room) {
}); });
}; };
/**
* @typedef {Object} module:crypto.DecryptionResult
*
* @property {Object} payload decrypted payload (with properties 'type',
* 'content').
*
* @property {Object<string, string>} keysClaimed keys that the sender of the
* event claims ownership of: map from key type to base64-encoded key
*
* @property {Object<string, string>} keysProved keys that the sender of the
* event is known to have ownership of: map from key type to base64-encoded
* key
*/
/** /**
* Decrypt a received event * Decrypt a received event
* *
* @param {object} event raw event * @param {MatrixEvent} event
*
* @return {module:crypto.DecryptionResult} decryption result
* *
* @raises {algorithms.DecryptionError} if there is a problem decrypting the event * @raises {algorithms.DecryptionError} if there is a problem decrypting the event
*/ */
Crypto.prototype.decryptEvent = function(event) { Crypto.prototype.decryptEvent = function(event) {
var content = event.content; var content = event.getWireContent();
var AlgClass = algorithms.DECRYPTION_CLASSES[content.algorithm]; var alg = this._getRoomDecryptor(event.getRoomId(), content.algorithm);
if (!AlgClass) { alg.decryptEvent(event);
throw new algorithms.DecryptionError("Unable to decrypt " + content.algorithm);
}
var alg = new AlgClass({
userId: this._userId,
olmDevice: this._olmDevice,
});
var r = alg.decryptEvent(event);
if (r !== null) {
return r;
} else {
// We've got a message for a session we don't have. Maybe the sender
// forgot to tell us about the session. Remind the sender that we
// exist so that they might tell us about the session on their next
// send.
//
// (Alternatively, it might be that we are just looking at
// scrollback... at least we rate-limit the m.new_device events :/)
//
// XXX: this is a band-aid which masks symptoms of other bugs. It would
// be nice to get rid of it.
if (event.room_id !== undefined && event.sender !== undefined) {
var device_id = event.content.device_id;
if (device_id === undefined) {
// if the sending device didn't tell us its device_id, fall
// back to all devices.
device_id = null;
}
this._sendPingToDevice(event.sender, device_id, event.room_id);
}
throw new algorithms.DecryptionError("Unknown inbound session id");
}
}; };
/** /**
@@ -1044,6 +998,11 @@ Crypto.prototype._onCryptoEvent = function(event) {
* @param {module:models/room[]} rooms list of rooms the client knows about * @param {module:models/room[]} rooms list of rooms the client knows about
*/ */
Crypto.prototype._onInitialSyncCompleted = function(rooms) { Crypto.prototype._onInitialSyncCompleted = function(rooms) {
this._initialSyncCompleted = true;
// catch up on any m.new_device events which arrived during the initial sync.
this._flushNewDeviceRequests();
if (this._sessionStore.getDeviceAnnounced()) { if (this._sessionStore.getDeviceAnnounced()) {
return; return;
} }
@@ -1056,7 +1015,7 @@ Crypto.prototype._onInitialSyncCompleted = function(rooms) {
var room = rooms[i]; var room = rooms[i];
// check for rooms with encryption enabled // check for rooms with encryption enabled
var alg = this._roomAlgorithms[room.roomId]; var alg = this._roomEncryptors[room.roomId];
if (!alg) { if (!alg) {
continue; continue;
} }
@@ -1110,16 +1069,13 @@ Crypto.prototype._onInitialSyncCompleted = function(rooms) {
*/ */
Crypto.prototype._onRoomKeyEvent = function(event) { Crypto.prototype._onRoomKeyEvent = function(event) {
var content = event.getContent(); var content = event.getContent();
var AlgClass = algorithms.DECRYPTION_CLASSES[content.algorithm];
if (!AlgClass) { if (!content.room_id || !content.algorithm) {
throw new algorithms.DecryptionError( console.error("key event is missing fields");
"Unable to handle keys for " + content.algorithm return;
);
} }
var alg = new AlgClass({
userId: this._userId, var alg = this._getRoomDecryptor(content.room_id, content.algorithm);
olmDevice: this._olmDevice,
});
alg.onRoomKeyEvent(event); alg.onRoomKeyEvent(event);
}; };
@@ -1143,7 +1099,7 @@ Crypto.prototype._onRoomMembership = function(event, member, oldMembership) {
var roomId = member.roomId; var roomId = member.roomId;
var alg = this._roomAlgorithms[roomId]; var alg = this._roomEncryptors[roomId];
if (!alg) { if (!alg) {
// not encrypting in this room // not encrypting in this room
return; return;
@@ -1173,26 +1129,110 @@ Crypto.prototype._onNewDeviceEvent = function(event) {
console.log("m.new_device event from " + userId + ":" + deviceId + console.log("m.new_device event from " + userId + ":" + deviceId +
" for rooms " + rooms); " for rooms " + rooms);
if (this.getStoredDevice(userId, deviceId)) {
console.log("Known device; ignoring");
return;
}
this._pendingNewDevices[userId] = this._pendingNewDevices[userId] || {};
this._pendingNewDevices[userId][deviceId] = true;
// we delay handling these until the intialsync has completed, so that we
// can do all of them together.
if (this._initialSyncCompleted) {
this._flushNewDeviceRequests();
}
};
/**
* Start device queries for any users who sent us an m.new_device recently
*/
Crypto.prototype._flushNewDeviceRequests = function() {
var self = this; var self = this;
this.downloadKeys(
[userId], true var pending = this._pendingNewDevices;
).then(function() { var users = utils.keys(pending).filter(function(u) {
for (var i = 0; i < rooms.length; i++) { return utils.keys(pending[u]).length > 0;
var roomId = rooms[i]; });
var alg = self._roomAlgorithms[roomId];
if (!alg) { if (users.length === 0) {
// not encrypting in this room return;
continue;
} }
alg.onNewDevice(userId, deviceId);
} var r = this._doKeyDownloadForUsers(users);
}).catch(function(e) {
// we've kicked off requests to these users: remove their
// pending flag for now.
this._pendingNewDevices = {};
users.map(function(u) {
r[u] = r[u].catch(function(e) {
console.error( console.error(
"Error updating device keys for new device " + userId + ":" + 'Error updating device keys for user ' + u + ':', e
deviceId,
e
); );
}).done();
// reinstate the pending flags on any users which failed; this will
// mean that we will do another download in the future, but won't
// tight-loop.
//
self._pendingNewDevices[u] = self._pendingNewDevices[u] || {};
utils.update(self._pendingNewDevices[u], pending[u]);
});
});
q.all(utils.values(r)).done();
};
/**
* Get a decryptor for a given room and algorithm.
*
* If we already have a decryptor for the given room and algorithm, return
* it. Otherwise try to instantiate it.
*
* @private
*
* @param {string?} roomId room id for decryptor. If undefined, a temporary
* decryptor is instantiated.
*
* @param {string} algorithm crypto algorithm
*
* @return {module:crypto.algorithms.base.DecryptionAlgorithm}
*
* @raises {module:crypto.algorithms.DecryptionError} if the algorithm is
* unknown
*/
Crypto.prototype._getRoomDecryptor = function(roomId, algorithm) {
var decryptors;
var alg;
roomId = roomId || null;
if (roomId) {
decryptors = this._roomDecryptors[roomId];
if (!decryptors) {
this._roomDecryptors[roomId] = decryptors = {};
}
alg = decryptors[algorithm];
if (alg) {
return alg;
}
}
var AlgClass = algorithms.DECRYPTION_CLASSES[algorithm];
if (!AlgClass) {
throw new algorithms.DecryptionError("Unable to decrypt " + algorithm);
}
alg = new AlgClass({
userId: this._userId,
crypto: this,
olmDevice: this._olmDevice,
roomId: roomId,
});
if (decryptors) {
decryptors[algorithm] = alg;
}
return alg;
}; };
@@ -1209,40 +1249,6 @@ Crypto.prototype._signObject = function(obj) {
obj.signatures = sigs; obj.signatures = sigs;
}; };
/**
* Verify the signature on an object
*
* @param {module:crypto/OlmDevice} olmDevice olm wrapper to use for verify op
*
* @param {Object} obj object to check signature on. Note that this will be
* stripped of its 'signatures' and 'unsigned' properties.
*
* @param {string} signingUserId ID of the user whose signature should be checked
*
* @param {string} signingDeviceId ID of the device whose signature should be checked
*
* @param {string} signingKey base64-ed ed25519 public key
*/
function _verifySignature(olmDevice, obj, signingUserId, signingDeviceId, signingKey) {
var signKeyId = "ed25519:" + signingDeviceId;
var signatures = obj.signatures || {};
var userSigs = signatures[signingUserId] || {};
var signature = userSigs[signKeyId];
if (!signature) {
throw Error("No signature");
}
// prepare the canonical json: remove unsigned and signatures, and stringify with
// anotherjson
delete obj.unsigned;
delete obj.signatures;
var json = anotherjson.stringify(obj);
olmDevice.verifySignature(
signingKey, json, signature
);
}
/** /**
* @see module:crypto/algorithms/base.DecryptionError * @see module:crypto/algorithms/base.DecryptionError
*/ */

View File

@@ -20,6 +20,9 @@ limitations under the License.
* Utilities common to olm encryption algorithms * Utilities common to olm encryption algorithms
*/ */
var q = require('q');
var anotherjson = require('another-json');
var utils = require("../utils"); var utils = require("../utils");
/** /**
@@ -100,3 +103,166 @@ module.exports.encryptMessageForDevice = function(
deviceKey, sessionId, JSON.stringify(payload) deviceKey, sessionId, JSON.stringify(payload)
); );
}; };
/**
* Try to make sure we have established olm sessions for the given devices.
*
* @param {module:crypto/OlmDevice} olmDevice
*
* @param {module:base-apis~MatrixBaseApis} baseApis
*
* @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
* map from userid to list of devices
*
* @return {module:client.Promise} resolves once the sessions are complete, to
* an Object mapping from userId to deviceId to
* {@link module:crypto~OlmSessionResult}
*/
module.exports.ensureOlmSessionsForDevices = function(
olmDevice, baseApis, devicesByUser
) {
var devicesWithoutSession = [
// [userId, deviceId], ...
];
var result = {};
for (var userId in devicesByUser) {
if (!devicesByUser.hasOwnProperty(userId)) { continue; }
result[userId] = {};
var devices = devicesByUser[userId];
for (var j = 0; j < devices.length; j++) {
var deviceInfo = devices[j];
var deviceId = deviceInfo.deviceId;
var key = deviceInfo.getIdentityKey();
var sessionId = olmDevice.getSessionIdForDevice(key);
if (sessionId === null) {
devicesWithoutSession.push([userId, deviceId]);
}
result[userId][deviceId] = {
device: deviceInfo,
sessionId: sessionId,
};
}
}
if (devicesWithoutSession.length === 0) {
return q(result);
}
// TODO: this has a race condition - if we try to send another message
// while we are claiming a key, we will end up claiming two and setting up
// two sessions.
//
// That should eventually resolve itself, but it's poor form.
var oneTimeKeyAlgorithm = "signed_curve25519";
return baseApis.claimOneTimeKeys(
devicesWithoutSession, oneTimeKeyAlgorithm
).then(function(res) {
for (var userId in devicesByUser) {
if (!devicesByUser.hasOwnProperty(userId)) { continue; }
var userRes = res.one_time_keys[userId] || {};
var devices = devicesByUser[userId];
for (var j = 0; j < devices.length; j++) {
var deviceInfo = devices[j];
var deviceId = deviceInfo.deviceId;
if (result[userId][deviceId].sessionId) {
// we already have a result for this device
continue;
}
var deviceRes = userRes[deviceId] || {};
var oneTimeKey = null;
for (var keyId in deviceRes) {
if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) {
oneTimeKey = deviceRes[keyId];
}
}
if (!oneTimeKey) {
console.warn(
"No one-time keys (alg=" + oneTimeKeyAlgorithm +
") for device " + userId + ":" + deviceId
);
continue;
}
var sid = _verifyKeyAndStartSession(
olmDevice, oneTimeKey, userId, deviceInfo
);
result[userId][deviceId].sessionId = sid;
}
}
return result;
});
};
function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo) {
var deviceId = deviceInfo.deviceId;
try {
_verifySignature(
olmDevice, oneTimeKey, userId, deviceId,
deviceInfo.getFingerprint()
);
} catch (e) {
console.error(
"Unable to verify signature on one-time key for device " +
userId + ":" + deviceId + ":", e
);
return null;
}
var sid;
try {
sid = olmDevice.createOutboundSession(
deviceInfo.getIdentityKey(), oneTimeKey.key
);
} catch (e) {
// possibly a bad key
console.error("Error starting session with device " +
userId + ":" + deviceId + ": " + e);
return null;
}
console.log("Started new sessionid " + sid +
" for device " + userId + ":" + deviceId);
return sid;
}
/**
* Verify the signature on an object
*
* @param {module:crypto/OlmDevice} olmDevice olm wrapper to use for verify op
*
* @param {Object} obj object to check signature on. Note that this will be
* stripped of its 'signatures' and 'unsigned' properties.
*
* @param {string} signingUserId ID of the user whose signature should be checked
*
* @param {string} signingDeviceId ID of the device whose signature should be checked
*
* @param {string} signingKey base64-ed ed25519 public key
*/
var _verifySignature = module.exports.verifySignature = function(
olmDevice, obj, signingUserId, signingDeviceId, signingKey
) {
var signKeyId = "ed25519:" + signingDeviceId;
var signatures = obj.signatures || {};
var userSigs = signatures[signingUserId] || {};
var signature = userSigs[signKeyId];
if (!signature) {
throw Error("No signature");
}
// prepare the canonical json: remove unsigned and signatures, and stringify with
// anotherjson
delete obj.unsigned;
delete obj.signatures;
var json = anotherjson.stringify(obj);
olmDevice.verifySignature(
signingKey, json, signature
);
};

View File

@@ -104,7 +104,7 @@ module.exports.MatrixHttpApi.prototype = {
* *
* @param {object} file The object to upload. On a browser, something that * @param {object} file The object to upload. On a browser, something that
* can be sent to XMLHttpRequest.send (typically a File). Under node.js, * can be sent to XMLHttpRequest.send (typically a File). Under node.js,
* a a Buffer, String or ReadStream. * a Buffer, String or ReadStream.
* *
* @param {object} opts options object * @param {object} opts options object
* *

View File

@@ -82,6 +82,27 @@ module.exports.request = function(r) {
request = r; request = r;
}; };
/**
* Return the currently-set request function.
* @return {requestFunction} The current request function.
*/
module.exports.getRequest = function() {
return request;
};
/**
* Apply wrapping code around the request function. The wrapper function is
* installed as the new request handler, and when invoked it is passed the
* previous value, along with the options and callback arguments.
* @param {requestWrapperFunction} wrapper The wrapping function.
*/
module.exports.wrapRequest = function(wrapper) {
var origRequest = request;
request = function(options, callback) {
return wrapper(origRequest, options, callback);
};
};
/** /**
* Construct a Matrix Client. Similar to {@link module:client~MatrixClient} * Construct a Matrix Client. Similar to {@link module:client~MatrixClient}
* except that the 'request', 'store' and 'scheduler' dependencies are satisfied. * except that the 'request', 'store' and 'scheduler' dependencies are satisfied.
@@ -129,6 +150,16 @@ module.exports.createClient = function(opts) {
* @param {requestCallback} callback The request callback. * @param {requestCallback} callback The request callback.
*/ */
/**
* A wrapper for the request function interface.
* @callback requestWrapperFunction
* @param {requestFunction} origRequest The underlying request function being
* wrapped
* @param {Object} opts The options for this HTTP request, given in the same
* form as {@link requestFunction}.
* @param {requestCallback} callback The request callback.
*/
/** /**
* The request callback interface for performing HTTP requests. This matches the * The request callback interface for performing HTTP requests. This matches the
* API for the {@link https://github.com/request/request#requestoptions-callback| * API for the {@link https://github.com/request/request#requestoptions-callback|

View File

@@ -21,6 +21,10 @@ limitations under the License.
* @module models/event * @module models/event
*/ */
var EventEmitter = require("events").EventEmitter;
var utils = require('../utils.js');
/** /**
* Enum for event statuses. * Enum for event statuses.
* @readonly * @readonly
@@ -51,15 +55,6 @@ module.exports.EventStatus = {
* *
* @param {Object} event The raw event to be wrapped in this DAO * @param {Object} event The raw event to be wrapped in this DAO
* *
* @param {Object=} clearEvent For encrypted events, the plaintext payload for
* the event (typically containing <tt>type</tt> and <tt>content</tt> fields).
*
* @param {Object=} keysProved Keys owned by the sender of this event.
* See {@link module:models/event.MatrixEvent#getKeysProved}.
*
* @param {Object=} keysClaimed Keys the sender of this event claims.
* See {@link module:models/event.MatrixEvent#getKeysClaimed}.
*
* @prop {Object} event The raw (possibly encrypted) event. <b>Do not access * @prop {Object} event The raw (possibly encrypted) event. <b>Do not access
* this property</b> directly unless you absolutely have to. Prefer the getter * this property</b> directly unless you absolutely have to. Prefer the getter
* methods defined on this class. Using the getter methods shields your app * methods defined on this class. Using the getter methods shields your app
@@ -75,22 +70,23 @@ module.exports.EventStatus = {
* Default: true. <strong>This property is experimental and may change.</strong> * Default: true. <strong>This property is experimental and may change.</strong>
*/ */
module.exports.MatrixEvent = function MatrixEvent( module.exports.MatrixEvent = function MatrixEvent(
event, clearEvent, keysProved, keysClaimed event
) { ) {
this.event = event || {}; this.event = event || {};
this.sender = null; this.sender = null;
this.target = null; this.target = null;
this.status = null; this.status = null;
this.forwardLooking = true; this.forwardLooking = true;
this._clearEvent = clearEvent || {};
this._pushActions = null; this._pushActions = null;
this._keysProved = keysProved || {}; this._clearEvent = {};
this._keysClaimed = keysClaimed || {}; this._keysProved = {};
this._keysClaimed = {};
}; };
utils.inherits(module.exports.MatrixEvent, EventEmitter);
module.exports.MatrixEvent.prototype = {
utils.extend(module.exports.MatrixEvent.prototype, {
/** /**
* Get the event_id for this event. * Get the event_id for this event.
@@ -239,12 +235,37 @@ module.exports.MatrixEvent.prototype = {
this._keysClaimed = keys; this._keysClaimed = keys;
}, },
/**
* Update the cleartext data on this event.
*
* (This is used after decrypting an event; it should not be used by applications).
*
* @internal
*
* @fires module:models/event.MatrixEvent#"Event.decrypted"
*
* @param {Object} clearEvent The plaintext payload for the event
* (typically containing <tt>type</tt> and <tt>content</tt> fields).
*
* @param {Object=} keysProved Keys owned by the sender of this event.
* See {@link module:models/event.MatrixEvent#getKeysProved}.
*
* @param {Object=} keysClaimed Keys the sender of this event claims.
* See {@link module:models/event.MatrixEvent#getKeysClaimed}.
*/
setClearData: function(clearEvent, keysProved, keysClaimed) {
this._clearEvent = clearEvent;
this._keysProved = keysProved || {};
this._keysClaimed = keysClaimed || {};
this.emit("Event.decrypted", this);
},
/** /**
* Check if the event is encrypted. * Check if the event is encrypted.
* @return {boolean} True if this event is encrypted. * @return {boolean} True if this event is encrypted.
*/ */
isEncrypted: function() { isEncrypted: function() {
return Boolean(this._clearEvent.type); return this.event.type === "m.room.encrypted";
}, },
/** /**
@@ -353,7 +374,7 @@ module.exports.MatrixEvent.prototype = {
setPushActions: function(pushActions) { setPushActions: function(pushActions) {
this._pushActions = pushActions; this._pushActions = pushActions;
}, },
}; });
/* http://matrix.org/docs/spec/r0.0.1/client_server.html#redactions says: /* http://matrix.org/docs/spec/r0.0.1/client_server.html#redactions says:
@@ -394,3 +415,15 @@ var _REDACT_KEEP_CONTENT_MAP = {
}, },
'm.room.aliases': {'aliases': 1}, 'm.room.aliases': {'aliases': 1},
}; };
/**
* Fires when an event is decrypted
*
* @event module:models/event.MatrixEvent#"Event.decrypted"
*
* @param {module:models/event.MatrixEvent} event
* The matrix event which has been decrypted
*/

View File

@@ -60,7 +60,9 @@ function debuglog() {
function SyncApi(client, opts) { function SyncApi(client, opts) {
this.client = client; this.client = client;
opts = opts || {}; opts = opts || {};
opts.initialSyncLimit = opts.initialSyncLimit || 8; opts.initialSyncLimit = (
opts.initialSyncLimit === undefined ? 8 : opts.initialSyncLimit
);
opts.resolveInvitesToProfiles = opts.resolveInvitesToProfiles || false; opts.resolveInvitesToProfiles = opts.resolveInvitesToProfiles || false;
opts.pollTimeout = opts.pollTimeout || (30 * 1000); opts.pollTimeout = opts.pollTimeout || (30 * 1000);
opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological";
@@ -542,6 +544,7 @@ SyncApi.prototype._sync = function(syncOptions) {
self._connectionReturnedDefer.reject(); self._connectionReturnedDefer.reject();
self._connectionReturnedDefer = null; self._connectionReturnedDefer = null;
} }
self._updateSyncState("STOPPED");
return; return;
} }
console.error("/sync error %s", err); console.error("/sync error %s", err);

View File

@@ -234,7 +234,7 @@ TimelineWindow.prototype.paginate = function(direction, size, makeRequest,
// remove some events from the other end, if necessary // remove some events from the other end, if necessary
var excess = this._eventCount - this._windowLimit; var excess = this._eventCount - this._windowLimit;
if (excess > 0) { if (excess > 0) {
this._unpaginate(excess, direction != EventTimeline.BACKWARDS); this.unpaginate(excess, direction != EventTimeline.BACKWARDS);
} }
return q(true); return q(true);
} }
@@ -287,15 +287,13 @@ TimelineWindow.prototype.paginate = function(direction, size, makeRequest,
/** /**
* Trim the window to the windowlimit * Remove `delta` events from the start or end of the timeline.
* *
* @param {number} delta number of events to remove from the timeline * @param {number} delta number of events to remove from the timeline
* @param {boolean} startOfTimeline if events should be removed from the start * @param {boolean} startOfTimeline if events should be removed from the start
* of the timeline. * of the timeline.
*
* @private
*/ */
TimelineWindow.prototype._unpaginate = function(delta, startOfTimeline) { TimelineWindow.prototype.unpaginate = function(delta, startOfTimeline) {
var tl = startOfTimeline ? this._start : this._end; var tl = startOfTimeline ? this._start : this._end;
// sanity-check the delta // sanity-check the delta

View File

@@ -204,7 +204,8 @@ module.exports.isFunction = function(value) {
* @return {boolean} True if it is an array. * @return {boolean} True if it is an array.
*/ */
module.exports.isArray = function(value) { module.exports.isArray = function(value) {
return Boolean(value && value.constructor === Array); return Array.isArray ? Array.isArray(value) :
Boolean(value && value.constructor === Array);
}; };
/** /**

View File

@@ -1,6 +1,6 @@
{ {
"name": "matrix-js-sdk", "name": "matrix-js-sdk",
"version": "0.6.4", "version": "0.7.0",
"description": "Matrix Client-Server SDK for Javascript", "description": "Matrix Client-Server SDK for Javascript",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@@ -10,8 +10,8 @@
set -e set -e
jq --version > /dev/null || (echo "jq is required: please install it"; exit) jq --version > /dev/null || (echo "jq is required: please install it"; kill $$)
hub --version > /dev/null || (echo "hub is required: please install it"; exit) hub --version > /dev/null || (echo "hub is required: please install it"; kill $$)
USAGE="$0 [-xz] [-c changelog_file] vX.Y.Z" USAGE="$0 [-xz] [-c changelog_file] vX.Y.Z"
@@ -140,7 +140,7 @@ if [ $dodist -eq 0 ]; then
echo "Building distribution copy in $builddir" echo "Building distribution copy in $builddir"
pushd "$builddir" pushd "$builddir"
git clone "$projdir" . git clone "$projdir" .
git co "$rel_branch" git checkout "$rel_branch"
npm install npm install
# We haven't tagged yet, so tell the dist script what version # We haven't tagged yet, so tell the dist script what version
# it's building # it's building

View File

@@ -5,21 +5,6 @@ var HttpBackend = require("../mock-request");
var utils = require("../../lib/utils"); var utils = require("../../lib/utils");
var test_utils = require("../test-utils"); var test_utils = require("../test-utils");
function MockStorageApi() {
this.data = {};
}
MockStorageApi.prototype = {
setItem: function(k, v) {
this.data[k] = v;
},
getItem: function(k) {
return this.data[k] || null;
},
removeItem: function(k) {
delete this.data[k];
}
};
var aliHttpBackend; var aliHttpBackend;
var bobHttpBackend; var bobHttpBackend;
var aliClient; var aliClient;
@@ -36,7 +21,6 @@ var aliDeviceKeys;
var bobDeviceKeys; var bobDeviceKeys;
var bobDeviceCurve25519Key; var bobDeviceCurve25519Key;
var bobDeviceEd25519Key; var bobDeviceEd25519Key;
var aliLocalStore;
var aliStorage; var aliStorage;
var bobStorage; var bobStorage;
var aliMessages; var aliMessages;
@@ -461,11 +445,9 @@ describe("MatrixClient crypto", function() {
} }
beforeEach(function() { beforeEach(function() {
aliLocalStore = new MockStorageApi();
aliStorage = new sdk.WebStorageSessionStore(aliLocalStore);
bobStorage = new sdk.WebStorageSessionStore(new MockStorageApi());
test_utils.beforeEach(this); test_utils.beforeEach(this);
aliStorage = new sdk.WebStorageSessionStore(new test_utils.MockStorageApi());
aliHttpBackend = new HttpBackend(); aliHttpBackend = new HttpBackend();
aliClient = sdk.createClient({ aliClient = sdk.createClient({
baseUrl: "http://alis.server", baseUrl: "http://alis.server",
@@ -476,6 +458,7 @@ describe("MatrixClient crypto", function() {
request: aliHttpBackend.requestFn, request: aliHttpBackend.requestFn,
}); });
bobStorage = new sdk.WebStorageSessionStore(new test_utils.MockStorageApi());
bobHttpBackend = new HttpBackend(); bobHttpBackend = new HttpBackend();
bobClient = sdk.createClient({ bobClient = sdk.createClient({
baseUrl: "http://bobs.server", baseUrl: "http://bobs.server",
@@ -500,6 +483,27 @@ describe("MatrixClient crypto", function() {
bobClient.stopClient(); bobClient.stopClient();
}); });
it('Ali knows the difference between a new user and one with no devices',
function(done) {
aliHttpBackend.when('POST', '/keys/query').respond(200, {
device_keys: {
'@bob:id': {},
}
});
var p1 = aliClient.downloadKeys(['@bob:id']);
var p2 = aliHttpBackend.flush('/keys/query', 1);
q.all([p1, p2]).then(function() {
var devices = aliStorage.getEndToEndDevicesForUser('@bob:id');
expect(utils.keys(devices).length).toEqual(0);
// request again: should be no more requests
return aliClient.downloadKeys(['@bob:id']);
}).nodeify(done);
}
);
it("Bob uploads without one-time keys and with one-time keys", function(done) { it("Bob uploads without one-time keys and with one-time keys", function(done) {
q() q()
.then(bobUploadsKeys) .then(bobUploadsKeys)
@@ -735,4 +739,31 @@ describe("MatrixClient crypto", function() {
}).then(aliRecvMessage) }).then(aliRecvMessage)
.catch(test_utils.failTest).done(done); .catch(test_utils.failTest).done(done);
}); });
it("Ali does a key query when she gets a new_device event", function(done) {
q()
.then(bobUploadsKeys)
.then(aliStartClient)
.then(function() {
var syncData = {
next_batch: '2',
to_device: {
events: [
test_utils.mkEvent({
content: {
device_id: 'TEST_DEVICE',
rooms: [],
},
sender: bobUserId,
type: 'm.new_device',
}),
],
},
};
aliHttpBackend.when('GET', '/sync').respond(200, syncData);
return aliHttpBackend.flush('/sync', 1);
}).then(expectAliQueryKeys)
.nodeify(done);
});
}); });

View File

@@ -371,21 +371,6 @@ describe("MatrixClient", function() {
httpBackend.flush(); httpBackend.flush();
}); });
it("should return a rejected promise if the request fails", function(done) {
httpBackend.when("POST", "/keys/query").respond(400);
var exceptionThrown;
client.downloadKeys(["bottom"]).then(function() {
fail("download didn't fail");
}, function(err) {
exceptionThrown = err;
}).then(function() {
expect(exceptionThrown).toBeTruthy();
}).catch(utils.failTest).done(done);
httpBackend.flush();
});
}); });
describe("deleteDevice", function() { describe("deleteDevice", function() {

617
spec/integ/megolm.spec.js Normal file
View File

@@ -0,0 +1,617 @@
/*
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";
try {
var Olm = require('olm');
} catch (e) {}
var anotherjson = require('another-json');
var q = require('q');
var sdk = require('../..');
var utils = require('../../lib/utils');
var test_utils = require('../test-utils');
var MockHttpBackend = require('../mock-request');
/**
* Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient
*
* @constructor
* @param {string} userId
* @param {string} deviceId
* @param {string} accessToken
*/
function TestClient(userId, deviceId, accessToken) {
this.userId = userId;
this.deviceId = deviceId;
this.storage = new sdk.WebStorageSessionStore(new test_utils.MockStorageApi());
this.httpBackend = new MockHttpBackend();
this.client = sdk.createClient({
baseUrl: "http://test.server",
userId: userId,
accessToken: accessToken,
deviceId: deviceId,
sessionStore: this.storage,
request: this.httpBackend.requestFn,
});
this.deviceKeys = null;
this.oneTimeKeys = [];
}
/**
* start the client, and wait for it to initialise.
*
* @return {Promise}
*/
TestClient.prototype.start = function() {
var self = this;
this.httpBackend.when("GET", "/pushrules").respond(200, {});
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
this.httpBackend.when("POST", "/keys/upload").respond(200, function(path, content) {
expect(content.one_time_keys).not.toBeDefined();
expect(content.device_keys).toBeDefined();
self.deviceKeys = content.device_keys;
return {one_time_key_counts: {signed_curve25519: 0}};
});
this.httpBackend.when("POST", "/keys/upload").respond(200, function(path, content) {
expect(content.device_keys).not.toBeDefined();
expect(content.one_time_keys).toBeDefined();
expect(content.one_time_keys).not.toEqual({});
self.oneTimeKeys = content.one_time_keys;
return {one_time_key_counts: {
signed_curve25519: utils.keys(self.oneTimeKeys).length
}};
});
this.client.startClient();
return this.httpBackend.flush();
};
/**
* stop the client
*/
TestClient.prototype.stop = function() {
this.client.stopClient();
};
/**
* get the uploaded curve25519 device key
*
* @return {string} base64 device key
*/
TestClient.prototype.getDeviceKey = function() {
var key_id = 'curve25519:' + this.deviceId;
return this.deviceKeys.keys[key_id];
};
/**
* get the uploaded ed25519 device key
*
* @return {string} base64 device key
*/
TestClient.prototype.getSigningKey = function() {
var key_id = 'ed25519:' + this.deviceId;
return this.deviceKeys.keys[key_id];
};
/**
* start an Olm session with a given recipient
*
* @param {Olm.Account} olmAccount
* @param {TestClient} recipientTestClient
* @return {Olm.Session}
*/
function createOlmSession(olmAccount, recipientTestClient) {
var otk_id = utils.keys(recipientTestClient.oneTimeKeys)[0];
var otk = recipientTestClient.oneTimeKeys[otk_id];
var session = new Olm.Session();
session.create_outbound(
olmAccount, recipientTestClient.getDeviceKey(), otk.key
);
return session;
}
/**
* encrypt an event with olm
*
* @param {object} opts
* @param {string=} opts.sender
* @param {string} opts.senderKey
* @param {Olm.Session} opts.p2pSession
* @param {TestClient} opts.recipient
* @param {object=} opts.plaincontent
* @param {string=} opts.plaintype
*
* @return {object} event
*/
function encryptOlmEvent(opts) {
expect(opts.senderKey).toBeDefined();
expect(opts.p2pSession).toBeDefined();
expect(opts.recipient).toBeDefined();
var plaintext = {
content: opts.plaincontent || {},
recipient: opts.recipient.userId,
recipient_keys: {
ed25519: opts.recipient.getSigningKey(),
},
sender: opts.sender || '@bob:xyz',
type: opts.plaintype || 'm.test',
};
var event = {
content: {
algorithm: 'm.olm.v1.curve25519-aes-sha2',
ciphertext: {},
sender_key: opts.senderKey,
},
sender: opts.sender || '@bob:xyz',
type: 'm.room.encrypted',
};
event.content.ciphertext[opts.recipient.getDeviceKey()] =
opts.p2pSession.encrypt(JSON.stringify(plaintext));
return event;
}
/**
* encrypt an event with megolm
*
* @param {object} opts
* @param {string} opts.senderKey
* @param {Olm.OutboundGroupSession} opts.groupSession
* @param {object=} opts.plaintext
* @param {string=} opts.room_id
*
* @return {object} event
*/
function encryptMegolmEvent(opts) {
expect(opts.senderKey).toBeDefined();
expect(opts.groupSession).toBeDefined();
var plaintext = opts.plaintext || {};
if (!plaintext.content) {
plaintext.content = {
body: '42',
msgtype: "m.text",
};
}
if (!plaintext.type) {
plaintext.type = "m.room.message";
}
if (!plaintext.room_id) {
expect(opts.room_id).toBeDefined();
plaintext.room_id = opts.room_id;
}
return {
content: {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: opts.groupSession.encrypt(JSON.stringify(plaintext)),
device_id: "testDevice",
sender_key: opts.senderKey,
session_id: opts.groupSession.session_id(),
},
type: "m.room.encrypted",
};
}
/**
* build an encrypted room_key event to share a group session
*
* @param {object} opts
* @param {string} opts.senderKey
* @param {TestClient} opts.recipient
* @param {Olm.Session} opts.p2pSession
* @param {Olm.OutboundGroupSession} opts.groupSession
* @param {string=} opts.room_id
*
* @return {object} event
*/
function encryptGroupSessionKey(opts) {
return encryptOlmEvent({
senderKey: opts.senderKey,
recipient: opts.recipient,
p2pSession: opts.p2pSession,
plaincontent: {
algorithm: 'm.megolm.v1.aes-sha2',
room_id: opts.room_id,
session_id: opts.groupSession.session_id(),
session_key: opts.groupSession.session_key(),
},
plaintype: 'm.room_key',
});
}
describe("megolm", function() {
if (!sdk.CRYPTO_ENABLED) {
return;
}
var ROOM_ID = "!room:id";
var testOlmAccount;
var testSenderKey;
var aliceTestClient;
var testDeviceKeys;
beforeEach(function(done) {
test_utils.beforeEach(this);
aliceTestClient = new TestClient(
"@alice:localhost", "xzcvb", "akjgkrgjs"
);
testOlmAccount = new Olm.Account();
testOlmAccount.create();
var testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
testSenderKey = testE2eKeys.curve25519;
testDeviceKeys = {
algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'],
device_id: 'DEVICE_ID',
keys: {
'curve25519:DEVICE_ID': testE2eKeys.curve25519,
'ed25519:DEVICE_ID': testE2eKeys.ed25519,
},
user_id: '@bob:xyz',
};
var j = anotherjson.stringify(testDeviceKeys);
var sig = testOlmAccount.sign(j);
testDeviceKeys.signatures = {
'@bob:xyz': {
'ed25519:DEVICE_ID': sig,
},
};
return aliceTestClient.start().nodeify(done);
});
afterEach(function() {
aliceTestClient.stop();
});
it("Alice receives a megolm message", function(done) {
var p2pSession = createOlmSession(testOlmAccount, aliceTestClient);
var groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// make the room_key event
var roomKeyEncrypted = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
});
// encrypt a message with the group session
var messageEncrypted = encryptMegolmEvent({
senderKey: testSenderKey,
groupSession: groupSession,
room_id: ROOM_ID,
});
// Alice gets both the events in a single sync
var syncResponse = {
next_batch: 1,
to_device: {
events: [roomKeyEncrypted],
},
rooms: {
join: {},
},
};
syncResponse.rooms.join[ROOM_ID] = {
timeline: {
events: [messageEncrypted],
},
};
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse);
return aliceTestClient.httpBackend.flush("/sync", 1).then(function() {
var room = aliceTestClient.client.getRoom(ROOM_ID);
var event = room.getLiveTimeline().getEvents()[0];
expect(event.getContent().body).toEqual('42');
}).nodeify(done);
});
it("Alice gets a second room_key message", function(done) {
var p2pSession = createOlmSession(testOlmAccount, aliceTestClient);
var groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// make the room_key event
var roomKeyEncrypted1 = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
});
// encrypt a message with the group session
var messageEncrypted = encryptMegolmEvent({
senderKey: testSenderKey,
groupSession: groupSession,
room_id: ROOM_ID,
});
// make a second room_key event now that we have advanced the group
// session.
var roomKeyEncrypted2 = encryptGroupSessionKey({
senderKey: testSenderKey,
recipient: aliceTestClient,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
});
// on the first sync, send the best room key
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, {
next_batch: 1,
to_device: {
events: [roomKeyEncrypted1],
},
});
// on the second sync, send the advanced room key, along with the
// message. This simulates the situation where Alice has been sent a
// later copy of the room key and is reloading the client.
var syncResponse2 = {
next_batch: 2,
to_device: {
events: [roomKeyEncrypted2],
},
rooms: {
join: {},
},
};
syncResponse2.rooms.join[ROOM_ID] = {
timeline: {
events: [messageEncrypted],
},
};
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse2);
return aliceTestClient.httpBackend.flush("/sync", 2).then(function() {
var room = aliceTestClient.client.getRoom(ROOM_ID);
var event = room.getLiveTimeline().getEvents()[0];
expect(event.getContent().body).toEqual('42');
}).nodeify(done);
});
it('Alice sends a megolm message', function(done) {
// establish an olm session with alice
var p2pSession = createOlmSession(testOlmAccount, aliceTestClient);
var olmEvent = encryptOlmEvent({
senderKey: testSenderKey,
recipient: aliceTestClient,
p2pSession: p2pSession,
});
var syncResponse = {
next_batch: 1,
to_device: {
events: [olmEvent],
},
rooms: {
join: {},
},
};
syncResponse.rooms.join[ROOM_ID] = {
state: {
events: [
test_utils.mkEvent({
type: 'm.room.encryption',
skey: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
}),
test_utils.mkMembership({
mship: 'join',
sender: '@bob:xyz',
}),
],
},
};
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse);
var inboundGroupSession;
return aliceTestClient.httpBackend.flush('/sync', 1).then(function() {
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(200, {
device_keys: {
'@bob:xyz': {
'DEVICE_ID': testDeviceKeys,
},
}
});
aliceTestClient.httpBackend.when(
'PUT', '/sendToDevice/m.room.encrypted/'
).respond(200, function(path, content) {
var m = content.messages['@bob:xyz'].DEVICE_ID;
var ct = m.ciphertext[testSenderKey];
var decrypted = JSON.parse(p2pSession.decrypt(ct.type, ct.body));
expect(decrypted.type).toEqual('m.room_key');
inboundGroupSession = new Olm.InboundGroupSession();
inboundGroupSession.create(decrypted.content.session_key);
return {};
});
aliceTestClient.httpBackend.when(
'PUT', '/send/'
).respond(200, function(path, content) {
var ct = content.ciphertext;
var decrypted = JSON.parse(inboundGroupSession.decrypt(ct));
console.log('Decrypted received megolm message', decrypted);
expect(decrypted.type).toEqual('m.room.message');
expect(decrypted.content.body).toEqual('test');
return {
event_id: '$event_id',
};
});
return q.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'),
aliceTestClient.httpBackend.flush(),
]);
}).nodeify(done);
});
it("Alice shouldn't do a second /query for non-e2e-capable devices", function(done) {
var syncResponse = {
next_batch: 1,
rooms: {
join: {},
},
};
syncResponse.rooms.join[ROOM_ID] = {
state: {
events: [
test_utils.mkEvent({
type: 'm.room.encryption',
skey: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
}),
test_utils.mkMembership({
mship: 'join',
sender: '@bob:xyz',
}),
],
},
};
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse);
return aliceTestClient.httpBackend.flush('/sync', 1).then(function() {
console.log("Forcing alice to download our device keys");
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(200, {
device_keys: {
'@bob:xyz': {},
}
});
return q.all([
aliceTestClient.client.downloadKeys(['@bob:xyz']),
aliceTestClient.httpBackend.flush('/keys/query', 1),
]);
}).then(function() {
console.log("Telling alice to send a megolm message");
aliceTestClient.httpBackend.when(
'PUT', '/send/'
).respond(200, {
event_id: '$event_id',
});
return q.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'),
aliceTestClient.httpBackend.flush(),
]);
}).nodeify(done);
});
it("We shouldn't attempt to send to blocked devices", function(done) {
// establish an olm session with alice
var p2pSession = createOlmSession(testOlmAccount, aliceTestClient);
var olmEvent = encryptOlmEvent({
senderKey: testSenderKey,
recipient: aliceTestClient,
p2pSession: p2pSession,
});
var syncResponse = {
next_batch: 1,
to_device: {
events: [olmEvent],
},
rooms: {
join: {},
},
};
syncResponse.rooms.join[ROOM_ID] = {
state: {
events: [
test_utils.mkEvent({
type: 'm.room.encryption',
skey: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
}),
test_utils.mkMembership({
mship: 'join',
sender: '@bob:xyz',
}),
],
},
};
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse);
return aliceTestClient.httpBackend.flush('/sync', 1).then(function() {
console.log('Forcing alice to download our device keys');
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(200, {
device_keys: {
'@bob:xyz': {
'DEVICE_ID': testDeviceKeys,
},
}
});
return q.all([
aliceTestClient.client.downloadKeys(['@bob:xyz']),
aliceTestClient.httpBackend.flush('/keys/query', 1),
]);
}).then(function() {
console.log('Telling alice to block our device');
aliceTestClient.client.setDeviceBlocked('@bob:xyz', 'DEVICE_ID');
console.log('Telling alice to send a megolm message');
aliceTestClient.httpBackend.when(
'PUT', '/send/'
).respond(200, {
event_id: '$event_id',
});
return q.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'),
aliceTestClient.httpBackend.flush(),
]);
}).nodeify(done);
});
});

View File

@@ -65,7 +65,7 @@ module.exports.mkEvent = function(opts) {
content: opts.content, content: opts.content,
event_id: "$" + Math.random() + "-" + Math.random() event_id: "$" + Math.random() + "-" + Math.random()
}; };
if (opts.skey) { if (opts.skey !== undefined) {
event.state_key = opts.skey; event.state_key = opts.skey;
} }
else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules", else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules",
@@ -159,7 +159,16 @@ module.exports.mkMessage = function(opts) {
* <p>This is useful for use with integration tests which use asyncronous * <p>This is useful for use with integration tests which use asyncronous
* methods: it can be added as a 'catch' handler in a promise chain. * methods: it can be added as a 'catch' handler in a promise chain.
* *
* @param {Error} error exception to be reported * @param {Error} err exception to be reported
*
* @deprecated
* It turns out there are easier ways of doing this. Just use nodeify():
*
* it("should not throw", function(done) {
* asynchronousMethod().then(function() {
* // some tests
* }).nodeify(done);
* });
* *
* @example * @example
* it("should not throw", function(done) { * it("should not throw", function(done) {
@@ -168,6 +177,27 @@ module.exports.mkMessage = function(opts) {
* }).catch(utils.failTest).done(done); * }).catch(utils.failTest).done(done);
* }); * });
*/ */
module.exports.failTest = function(error) { module.exports.failTest = function(err) {
expect(error.stack).toBe(null); expect(true).toBe(false, "Testfunc threw: " + err.stack);
};
/**
* A mock implementation of webstorage
*
* @constructor
*/
module.exports.MockStorageApi = function() {
this.data = {};
};
module.exports.MockStorageApi.prototype = {
setItem: function(k, v) {
this.data[k] = v;
},
getItem: function(k) {
return this.data[k] || null;
},
removeItem: function(k) {
delete this.data[k];
}
}; };