diff --git a/lib/crypto/OlmDevice.js b/lib/crypto/OlmDevice.js index 1d7e8598c..07335e548 100644 --- a/lib/crypto/OlmDevice.js +++ b/lib/crypto/OlmDevice.js @@ -627,6 +627,24 @@ OlmDevice.prototype.addInboundGroupSession = function( roomId, senderKey, sessionId, sessionKey, keysClaimed ) { 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(); try { session.create(sessionKey); diff --git a/spec/integ/matrix-client-crypto.spec.js b/spec/integ/matrix-client-crypto.spec.js index 14207eafe..5066304a0 100644 --- a/spec/integ/matrix-client-crypto.spec.js +++ b/spec/integ/matrix-client-crypto.spec.js @@ -5,21 +5,6 @@ var HttpBackend = require("../mock-request"); var utils = require("../../lib/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 bobHttpBackend; var aliClient; @@ -36,7 +21,6 @@ var aliDeviceKeys; var bobDeviceKeys; var bobDeviceCurve25519Key; var bobDeviceEd25519Key; -var aliLocalStore; var aliStorage; var bobStorage; var aliMessages; @@ -461,11 +445,9 @@ describe("MatrixClient crypto", function() { } beforeEach(function() { - aliLocalStore = new MockStorageApi(); - aliStorage = new sdk.WebStorageSessionStore(aliLocalStore); - bobStorage = new sdk.WebStorageSessionStore(new MockStorageApi()); test_utils.beforeEach(this); + aliStorage = new sdk.WebStorageSessionStore(new test_utils.MockStorageApi()); aliHttpBackend = new HttpBackend(); aliClient = sdk.createClient({ baseUrl: "http://alis.server", @@ -476,6 +458,7 @@ describe("MatrixClient crypto", function() { request: aliHttpBackend.requestFn, }); + bobStorage = new sdk.WebStorageSessionStore(new test_utils.MockStorageApi()); bobHttpBackend = new HttpBackend(); bobClient = sdk.createClient({ baseUrl: "http://bobs.server", diff --git a/spec/integ/megolm.spec.js b/spec/integ/megolm.spec.js new file mode 100644 index 000000000..f30aa428e --- /dev/null +++ b/spec/integ/megolm.spec.js @@ -0,0 +1,366 @@ +/* +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 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]; +}; + + +/** + * 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 {string} opts.recipientKey + * @param {Olm.Session} opts.p2pSession + * @param {object} opts.plaintext + * + * @return {object} event + */ +function encryptOlmEvent(opts) { + expect(opts.senderKey).toBeDefined(); + expect(opts.p2pSession).toBeDefined(); + expect(opts.plaintext).toBeDefined(); + expect(opts.recipientKey).toBeDefined(); + + 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.recipientKey] = + opts.p2pSession.encrypt(JSON.stringify(opts.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 {string} opts.recipientKey + * @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, + recipientKey: opts.recipientKey, + p2pSession: opts.p2pSession, + plaintext: { + content: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: opts.room_id, + session_id: opts.groupSession.session_id(), + session_key: opts.groupSession.session_key(), + }, + type: "m.room_key", + }, + }); +} + +describe("megolm", function() { + if (!sdk.CRYPTO_ENABLED) { + return; + } + + var ROOM_ID = "!room:id"; + + var testOlmAccount; + var testSenderKey; + var aliceTestClient; + + beforeEach(test_utils.asyncTest(function() { + 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; + + return aliceTestClient.start(); + })); + + afterEach(function() { + aliceTestClient.stop(); + }); + + it("Alice receives a megolm message", test_utils.asyncTest(function() { + var p2pSession = createOlmSession(testOlmAccount, aliceTestClient); + + var groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // make the room_key event + var roomKeyEncrypted = encryptGroupSessionKey({ + senderKey: testSenderKey, + recipientKey: aliceTestClient.getDeviceKey(), + 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'); + }); + })); + + it("Alice gets a second room_key message", test_utils.asyncTest(function() { + var p2pSession = createOlmSession(testOlmAccount, aliceTestClient); + + var groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // make the room_key event + var roomKeyEncrypted1 = encryptGroupSessionKey({ + senderKey: testSenderKey, + recipientKey: aliceTestClient.getDeviceKey(), + 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, + recipientKey: aliceTestClient.getDeviceKey(), + 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'); + }); + + })); +}); diff --git a/spec/test-utils.js b/spec/test-utils.js index ecee27331..109301e14 100644 --- a/spec/test-utils.js +++ b/spec/test-utils.js @@ -159,7 +159,7 @@ module.exports.mkMessage = function(opts) { *
This is useful for use with integration tests which use asyncronous * 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 * * @example * it("should not throw", function(done) { @@ -168,6 +168,48 @@ module.exports.mkMessage = function(opts) { * }).catch(utils.failTest).done(done); * }); */ -module.exports.failTest = function(error) { - expect(error.stack).toBe(null); +module.exports.failTest = function(err) { + expect(true).toBe(false, "Testfunc threw: " + err.stack); +}; + +/** + * Wrap a test function which returns a promise into a format which + * jasmine will understand. + * + * @param {Function} testfunc test function, which should return a promise + * + * @return {Function} + * + * @example + * it("should not throw", asyncTest(function() { + * return asynchronousMethod().then(function() { + * // some tests + * }); + * })); + */ +module.exports.asyncTest = function(testfunc) { + return function(done) { + testfunc.call(this).catch(module.exports.failTest).done(done); + }; +}; + + +/** + * 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]; + } };