1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-28 05:03:59 +03:00

Merge branch 'e2e_backups' of git://github.com/uhoreg/matrix-js-sdk into uhoreg-e2e_backups

This commit is contained in:
David Baker
2018-10-24 17:15:36 +01:00
16 changed files with 1476 additions and 62 deletions

View File

@@ -56,6 +56,7 @@
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"bluebird": "^3.5.0", "bluebird": "^3.5.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"bs58": "^4.0.1",
"content-type": "^1.0.2", "content-type": "^1.0.2",
"request": "^2.53.0" "request": "^2.53.0"
}, },

View File

@@ -1,20 +1,24 @@
"use strict"; "use strict";
import 'source-map-support/register'; import 'source-map-support/register';
import Crypto from '../../lib/crypto';
import expect from 'expect';
const sdk = require("../.."); const sdk = require("../..");
let Crypto;
if (sdk.CRYPTO_ENABLED) {
Crypto = require("../../lib/crypto");
}
import expect from 'expect'; const Olm = global.Olm;
describe("Crypto", function() { describe("Crypto", function() {
if (!sdk.CRYPTO_ENABLED) { if (!sdk.CRYPTO_ENABLED) {
return; return;
} }
beforeEach(function(done) {
Olm.init().then(done);
});
it("Crypto exposes the correct olm library version", function() { it("Crypto exposes the correct olm library version", function() {
console.log(Crypto);
expect(Crypto.getOlmVersion()[0]).toEqual(2); expect(Crypto.getOlmVersion()[0]).toEqual(2);
}); });
}); });

View File

@@ -13,20 +13,16 @@ import WebStorageSessionStore from '../../../../lib/store/session/webstorage';
import MemoryCryptoStore from '../../../../lib/crypto/store/memory-crypto-store.js'; import MemoryCryptoStore from '../../../../lib/crypto/store/memory-crypto-store.js';
import MockStorageApi from '../../../MockStorageApi'; import MockStorageApi from '../../../MockStorageApi';
import testUtils from '../../../test-utils'; import testUtils from '../../../test-utils';
import OlmDevice from '../../../../lib/crypto/OlmDevice';
// Crypto and OlmDevice won't import unless we have global.Olm import Crypto from '../../../../lib/crypto';
let OlmDevice;
let Crypto;
if (global.Olm) {
OlmDevice = require('../../../../lib/crypto/OlmDevice');
Crypto = require('../../../../lib/crypto');
}
const MatrixEvent = sdk.MatrixEvent; const MatrixEvent = sdk.MatrixEvent;
const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
const ROOM_ID = '!ROOM:ID'; const ROOM_ID = '!ROOM:ID';
const Olm = global.Olm;
describe("MegolmDecryption", function() { describe("MegolmDecryption", function() {
if (!global.Olm) { if (!global.Olm) {
console.warn('Not running megolm unit tests: libolm not present'); console.warn('Not running megolm unit tests: libolm not present');
@@ -69,7 +65,8 @@ describe("MegolmDecryption", function() {
describe('receives some keys:', function() { describe('receives some keys:', function() {
let groupSession; let groupSession;
beforeEach(function() { beforeEach(async function() {
await Olm.init();
groupSession = new global.Olm.OutboundGroupSession(); groupSession = new global.Olm.OutboundGroupSession();
groupSession.create(); groupSession.create();
@@ -98,7 +95,7 @@ describe("MegolmDecryption", function() {
}, },
}; };
return event.attemptDecryption(mockCrypto).then(() => { await event.attemptDecryption(mockCrypto).then(() => {
megolmDecryption.onRoomKeyEvent(event); megolmDecryption.onRoomKeyEvent(event);
}); });
}); });

View File

@@ -0,0 +1,480 @@
/*
Copyright 2018 New Vector 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.
*/
try {
global.Olm = require('olm');
} catch (e) {
console.warn("unable to run megolm backup tests: libolm not available");
}
import expect from 'expect';
import Promise from 'bluebird';
import sdk from '../../..';
import algorithms from '../../../lib/crypto/algorithms';
import WebStorageSessionStore from '../../../lib/store/session/webstorage';
import MemoryCryptoStore from '../../../lib/crypto/store/memory-crypto-store.js';
import MockStorageApi from '../../MockStorageApi';
import testUtils from '../../test-utils';
// Crypto and OlmDevice won't import unless we have global.Olm
let OlmDevice;
let Crypto;
if (global.Olm) {
OlmDevice = require('../../../lib/crypto/OlmDevice');
Crypto = require('../../../lib/crypto');
}
const Olm = global.Olm;
const MatrixClient = sdk.MatrixClient;
const MatrixEvent = sdk.MatrixEvent;
const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
const ROOM_ID = '!ROOM:ID';
const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc';
const ENCRYPTED_EVENT = new MatrixEvent({
type: 'm.room.encrypted',
room_id: '!ROOM:ID',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
sender_key: 'SENDER_CURVE25519',
session_id: SESSION_ID,
ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N'
+ 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl'
+ 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs',
},
event_id: '$event1',
origin_server_ts: 1507753886000,
});
const KEY_BACKUP_DATA = {
first_message_index: 0,
forwarded_count: 0,
is_verified: false,
session_data: {
ciphertext: '2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw'
+ '6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ'
+ 'Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9'
+ 'SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy'
+ 'Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF'
+ 'ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV'
+ '4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv'
+ 'C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe'
+ 'Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf'
+ 'QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy'
+ 'iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg',
mac: '5lxYBHQU80M',
ephemeral: '/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14',
},
};
describe("MegolmBackup", function() {
if (!global.Olm) {
console.warn('Not running megolm backup unit tests: libolm not present');
return;
}
let olmDevice;
let mockOlmLib;
let mockCrypto;
let mockStorage;
let sessionStore;
let cryptoStore;
let megolmDecryption;
beforeEach(async function() {
await Olm.init();
testUtils.beforeEach(this); // eslint-disable-line no-invalid-this
mockCrypto = testUtils.mock(Crypto, 'Crypto');
mockCrypto.backupKey = new Olm.PkEncryption();
mockCrypto.backupKey.set_recipient_key(
"hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
);
mockCrypto.backupInfo = {
algorithm: "m.megolm_backup.v1",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
mockStorage = new MockStorageApi();
sessionStore = new WebStorageSessionStore(mockStorage);
cryptoStore = new MemoryCryptoStore(mockStorage);
olmDevice = new OlmDevice(sessionStore, cryptoStore);
// we stub out the olm encryption bits
mockOlmLib = {};
mockOlmLib.ensureOlmSessionsForDevices = expect.createSpy();
mockOlmLib.encryptMessageForDevice =
expect.createSpy().andReturn(Promise.resolve());
});
describe("backup", function() {
let mockBaseApis;
beforeEach(function() {
mockBaseApis = {};
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: mockBaseApis,
roomId: ROOM_ID,
});
megolmDecryption.olmlib = mockOlmLib;
});
it('automatically calls the key back up', function() {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// construct a fake decrypted key event via the use of a mocked
// 'crypto' implementation.
const event = new MatrixEvent({
type: 'm.room.encrypted',
});
const decryptedData = {
clearEvent: {
type: 'm.room_key',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
room_id: ROOM_ID,
session_id: groupSession.session_id(),
session_key: groupSession.session_key(),
},
},
senderCurve25519Key: "SENDER_CURVE25519",
claimedEd25519Key: "SENDER_ED25519",
};
mockCrypto.decryptEvent = function() {
return Promise.resolve(decryptedData);
};
mockCrypto.cancelRoomKeyRequest = function() {};
mockCrypto.backupGroupSession = expect.createSpy();
return event.attemptDecryption(mockCrypto).then(() => {
return megolmDecryption.onRoomKeyEvent(event);
}).then(() => {
expect(mockCrypto.backupGroupSession).toHaveBeenCalled();
});
});
it('sends backups to the server', function() {
this.timeout(12000); // eslint-disable-line no-invalid-this
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
"setProcessFunction",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
const store = [
"getRoom", "getRooms", "getUser", "getSyncToken", "scrollback",
"save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom",
"storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter",
"storeFilter", "getSyncAccumulator", "startup", "deleteAllData",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null));
store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null));
store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null));
const client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
request: function() {}, // NOP
store: store,
scheduler: scheduler,
userId: "@alice:bar",
deviceId: "device",
sessionStore: sessionStore,
cryptoStore: cryptoStore,
});
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
});
megolmDecryption.olmlib = mockOlmLib;
return client.initCrypto()
.then(() => {
return cryptoStore.doTxn(
"readwrite",
[cryptoStore.STORE_SESSION],
(txn) => {
cryptoStore.addEndToEndInboundGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
{
forwardingCurve25519KeyChain: undefined,
keysClaimed: {
ed25519: "SENDER_ED25519",
},
room_id: ROOM_ID,
session: ibGroupSession.pickle(olmDevice._pickleKey),
},
txn);
});
})
.then(() => {
client.enableKeyBackup({
algorithm: "m.megolm_backup.v1",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
let numCalls = 0;
return new Promise((resolve, reject) => {
client._http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
expect(++numCalls <= 1);
if (numCalls >= 2) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams.version).toBe(1);
expect(data.rooms[ROOM_ID].sessions).toExist();
expect(data.rooms[ROOM_ID].sessions).toIncludeKey(
groupSession.session_id(),
);
resolve();
return Promise.resolve({});
};
client._crypto.backupGroupSession(
"roomId",
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
[],
groupSession.session_id(),
groupSession.session_key(),
);
})
.then(() => {
expect(numCalls).toBe(1);
});
});
});
it('retries when a backup fails', function() {
this.timeout(12000); // eslint-disable-line no-invalid-this
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
"setProcessFunction",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
const store = [
"getRoom", "getRooms", "getUser", "getSyncToken", "scrollback",
"save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom",
"storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter",
"storeFilter", "getSyncAccumulator", "startup", "deleteAllData",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null));
store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null));
store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null));
const client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
request: function() {}, // NOP
store: store,
scheduler: scheduler,
userId: "@alice:bar",
deviceId: "device",
sessionStore: sessionStore,
cryptoStore: cryptoStore,
});
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
});
megolmDecryption.olmlib = mockOlmLib;
return client.initCrypto()
.then(() => {
return cryptoStore.doTxn(
"readwrite",
[cryptoStore.STORE_SESSION],
(txn) => {
cryptoStore.addEndToEndInboundGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
{
forwardingCurve25519KeyChain: undefined,
keysClaimed: {
ed25519: "SENDER_ED25519",
},
room_id: ROOM_ID,
session: ibGroupSession.pickle(olmDevice._pickleKey),
},
txn);
});
})
.then(() => {
client.enableKeyBackup({
algorithm: "foobar",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
let numCalls = 0;
return new Promise((resolve, reject) => {
client._http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
expect(++numCalls <= 2);
if (numCalls >= 3) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams.version).toBe(1);
expect(data.rooms[ROOM_ID].sessions).toExist();
expect(data.rooms[ROOM_ID].sessions).toIncludeKey(
groupSession.session_id(),
);
if (numCalls > 1) {
resolve();
return Promise.resolve({});
} else {
return Promise.reject(
new Error("this is an expected failure"),
);
}
};
client._crypto.backupGroupSession(
"roomId",
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
[],
groupSession.session_id(),
groupSession.session_key(),
);
})
.then(() => {
expect(numCalls).toBe(2);
});
});
});
});
describe("restore", function() {
let client;
beforeEach(function() {
const scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
"setProcessFunction",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
const store = [
"getRoom", "getRooms", "getUser", "getSyncToken", "scrollback",
"save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom",
"storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter",
"storeFilter", "getSyncAccumulator", "startup", "deleteAllData",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null));
store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null));
store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null));
client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
request: function() {}, // NOP
store: store,
scheduler: scheduler,
userId: "@alice:bar",
deviceId: "device",
sessionStore: sessionStore,
cryptoStore: cryptoStore,
});
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
});
megolmDecryption.olmlib = mockOlmLib;
return client.initCrypto();
});
it('can restore from backup', function() {
client._http.authedRequest = function() {
return Promise.resolve(KEY_BACKUP_DATA);
};
return client.restoreKeyBackups(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
ROOM_ID,
SESSION_ID,
).then(() => {
return megolmDecryption.decryptEvent(ENCRYPTED_EVENT);
}).then((res) => {
expect(res.clearEvent.content).toEqual('testytest');
});
});
it('can restore backup by room', function() {
client._http.authedRequest = function() {
return Promise.resolve({
rooms: {
[ROOM_ID]: {
sessions: {
[SESSION_ID]: KEY_BACKUP_DATA,
},
},
},
});
};
return client.restoreKeyBackups(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
).then(() => {
return megolmDecryption.decryptEvent(ENCRYPTED_EVENT);
}).then((res) => {
expect(res.clearEvent.content).toEqual('testytest');
});
});
});
});

View File

@@ -41,23 +41,44 @@ const SyncApi = require("./sync");
const MatrixBaseApis = require("./base-apis"); const MatrixBaseApis = require("./base-apis");
const MatrixError = httpApi.MatrixError; const MatrixError = httpApi.MatrixError;
const ContentHelpers = require("./content-helpers"); const ContentHelpers = require("./content-helpers");
const olmlib = require("./crypto/olmlib");
import ReEmitter from './ReEmitter'; import ReEmitter from './ReEmitter';
import RoomList from './crypto/RoomList'; import RoomList from './crypto/RoomList';
import Crypto from './crypto';
import { isCryptoAvailable } from './crypto';
import { encodeRecoveryKey, decodeRecoveryKey } from './crypto/recoverykey';
// Disable warnings for now: we use deprecated bluebird functions // Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings. // and need to migrate, but they spam the console with warnings.
Promise.config({warnings: false}); Promise.config({warnings: false});
const SCROLLBACK_DELAY_MS = 3000; const SCROLLBACK_DELAY_MS = 3000;
let CRYPTO_ENABLED = false; const CRYPTO_ENABLED = isCryptoAvailable();
function keysFromRecoverySession(sessions, decryptionKey, roomId) {
const keys = [];
for (const [sessionId, sessionData] of Object.entries(sessions)) {
try { try {
var Crypto = require("./crypto"); const decrypted = keyFromRecoverySession(sessionData, decryptionKey);
CRYPTO_ENABLED = true; decrypted.session_id = sessionId;
decrypted.room_id = roomId;
keys.push(decrypted);
} catch (e) { } catch (e) {
console.warn("Unable to load crypto module: crypto will be disabled: " + e); console.log("Failed to decrypt session from backup");
}
}
return keys;
}
function keyFromRecoverySession(session, decryptionKey) {
return JSON.parse(decryptionKey.decrypt(
session.session_data.ephemeral,
session.session_data.mac,
session.session_data.ciphertext,
));
} }
/** /**
@@ -133,6 +154,8 @@ function MatrixClient(opts) {
MatrixBaseApis.call(this, opts); MatrixBaseApis.call(this, opts);
this.olmVersion = null; // Populated after initCrypto is done
this.reEmitter = new ReEmitter(this); this.reEmitter = new ReEmitter(this);
this.store = opts.store || new StubStore(); this.store = opts.store || new StubStore();
@@ -185,10 +208,6 @@ function MatrixClient(opts) {
this._forceTURN = opts.forceTURN || false; this._forceTURN = opts.forceTURN || false;
if (CRYPTO_ENABLED) {
this.olmVersion = Crypto.getOlmVersion();
}
// List of which rooms have encryption enabled: separate from crypto because // List of which rooms have encryption enabled: separate from crypto because
// we still want to know which rooms are encrypted even if crypto is disabled: // we still want to know which rooms are encrypted even if crypto is disabled:
// we don't want to start sending unencrypted events to them. // we don't want to start sending unencrypted events to them.
@@ -378,6 +397,13 @@ MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) {
* successfully initialised. * successfully initialised.
*/ */
MatrixClient.prototype.initCrypto = async function() { MatrixClient.prototype.initCrypto = async function() {
if (!isCryptoAvailable()) {
throw new Error(
`End-to-end encryption not supported in this js-sdk build: did ` +
`you remember to load the olm library?`,
);
}
if (this._crypto) { if (this._crypto) {
console.warn("Attempt to re-initialise e2e encryption on MatrixClient"); console.warn("Attempt to re-initialise e2e encryption on MatrixClient");
return; return;
@@ -395,13 +421,6 @@ MatrixClient.prototype.initCrypto = async function() {
// initialise the list of encrypted rooms (whether or not crypto is enabled) // initialise the list of encrypted rooms (whether or not crypto is enabled)
await this._roomList.init(); await this._roomList.init();
if (!CRYPTO_ENABLED) {
throw new Error(
`End-to-end encryption not supported in this js-sdk build: did ` +
`you remember to load the olm library?`,
);
}
const userId = this.getUserId(); const userId = this.getUserId();
if (userId === null) { if (userId === null) {
throw new Error( throw new Error(
@@ -433,6 +452,9 @@ MatrixClient.prototype.initCrypto = async function() {
await crypto.init(); await crypto.init();
this.olmVersion = Crypto.getOlmVersion();
// if crypto initialisation was successful, tell it to attach its event // if crypto initialisation was successful, tell it to attach its event
// handlers. // handlers.
crypto.registerEventHandlers(this); crypto.registerEventHandlers(this);
@@ -536,7 +558,15 @@ MatrixClient.prototype.setDeviceVerified = function(userId, deviceId, verified)
if (verified === undefined) { if (verified === undefined) {
verified = true; verified = true;
} }
return _setDeviceVerification(this, userId, deviceId, verified, null); const prom = _setDeviceVerification(this, userId, deviceId, verified, null);
// if one of the user's own devices is being marked as verified / unverified,
// check the key backup status, since whether or not we use this depends on
// whether it has a signature from a verified device
if (userId == this.credentials.userId) {
this._crypto.checkKeyBackup();
}
return prom;
}; };
/** /**
@@ -740,6 +770,303 @@ MatrixClient.prototype.importRoomKeys = function(keys) {
return this._crypto.importRoomKeys(keys); return this._crypto.importRoomKeys(keys);
}; };
/**
* Get information about the current key backup.
* @returns {Promise} Information object from API or null
*/
MatrixClient.prototype.getKeyBackupVersion = function() {
return this._http.authedRequest(
undefined, "GET", "/room_keys/version",
).then((res) => {
if (res.algorithm !== olmlib.MEGOLM_BACKUP_ALGORITHM) {
const err = "Unknown backup algorithm: " + res.algorithm;
return Promise.reject(err);
} else if (!(typeof res.auth_data === "object")
|| !res.auth_data.public_key) {
const err = "Invalid backup data returned";
return Promise.reject(err);
} else {
return res;
}
}).catch((e) => {
if (e.errcode === 'M_NOT_FOUND') {
return null;
} else {
throw e;
}
});
};
/**
* @param {object} info key backup info dict from getKeyBackupVersion()
* @return {object} {
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
* sigs: [
* valid: [bool],
* device: [DeviceInfo],
* ]
* }
*/
MatrixClient.prototype.isKeyBackupTrusted = function(info) {
return this._crypto.isKeyBackupTrusted(info);
};
/**
* @returns {bool} true if the client is configured to back up keys to
* the server, otherwise false.
*/
MatrixClient.prototype.getKeyBackupEnabled = function() {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
return Boolean(this._crypto.backupKey);
};
/**
* Enable backing up of keys, using data previously returned from
* getKeyBackupVersion.
*
* @param {object} info Backup information object as returned by getKeyBackupVersion
*/
MatrixClient.prototype.enableKeyBackup = function(info) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
this._crypto.backupInfo = info;
if (this._crypto.backupKey) this._crypto.backupKey.free();
this._crypto.backupKey = new global.Olm.PkEncryption();
this._crypto.backupKey.set_recipient_key(info.auth_data.public_key);
this.emit('keyBackupStatus', true);
this._crypto._maybeSendKeyBackup();
};
/**
* Disable backing up of keys.
*/
MatrixClient.prototype.disableKeyBackup = function() {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
this._crypto.backupInfo = null;
if (this._crypto.backupKey) this._crypto.backupKey.free();
this._crypto.backupKey = null;
this.emit('keyBackupStatus', false);
};
/**
* Set up the data required to create a new backup version. The backup version
* will not be created and enabled until createKeyBackupVersion is called.
*
* @returns {object} Object that can be passed to createKeyBackupVersion and
* additionally has a 'recovery_key' member with the user-facing recovery key string.
*/
MatrixClient.prototype.prepareKeyBackupVersion = function() {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
const decryption = new global.Olm.PkDecryption();
try {
const publicKey = decryption.generate_key();
return {
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
auth_data: {
public_key: publicKey,
},
recovery_key: encodeRecoveryKey(decryption.get_private_key()),
};
} finally {
decryption.free();
}
};
/**
* Create a new key backup version and enable it, using the information return
* from prepareKeyBackupVersion.
*
* @param {object} info Info object from prepareKeyBackupVersion
* @returns {Promise<object>} Object with 'version' param indicating the version created
*/
MatrixClient.prototype.createKeyBackupVersion = function(info) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
const data = {
algorithm: info.algorithm,
auth_data: info.auth_data, // FIXME: should this be cloned?
};
return this._crypto._signObject(data.auth_data).then(() => {
return this._http.authedRequest(
undefined, "POST", "/room_keys/version", undefined, data,
);
}).then((res) => {
this.enableKeyBackup({
algorithm: info.algorithm,
auth_data: info.auth_data,
version: res.version,
});
return res;
});
};
MatrixClient.prototype.deleteKeyBackupVersion = function(version) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
// If we're currently backing up to this backup... stop.
// (We start using it automatically in createKeyBackupVersion
// so this is symmetrical).
if (this._crypto.backupInfo && this._crypto.backupInfo.version === version) {
this.disableKeyBackup();
}
const path = utils.encodeUri("/room_keys/version/$version", {
$version: version,
});
return this._http.authedRequest(
undefined, "DELETE", path, undefined, undefined,
);
};
MatrixClient.prototype._makeKeyBackupPath = function(roomId, sessionId, version) {
let path;
if (sessionId !== undefined) {
path = utils.encodeUri("/room_keys/keys/$roomId/$sessionId", {
$roomId: roomId,
$sessionId: sessionId,
});
} else if (roomId !== undefined) {
path = utils.encodeUri("/room_keys/keys/$roomId", {
$roomId: roomId,
});
} else {
path = "/room_keys/keys";
}
const queryData = version === undefined ? undefined : { version: version };
return {
path: path,
queryData: queryData,
};
};
/**
* Back up session keys to the homeserver.
* @param {string} roomId ID of the room that the keys are for Optional.
* @param {string} sessionId ID of the session that the keys are for Optional.
* @param {integer} version backup version Optional.
* @param {object} data Object keys to send
* @return {module:client.Promise} a promise that will resolve when the keys
* are uploaded
*/
MatrixClient.prototype.sendKeyBackup = function(roomId, sessionId, version, data) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
const path = this._makeKeyBackupPath(roomId, sessionId, version);
return this._http.authedRequest(
undefined, "PUT", path.path, path.queryData, data,
);
};
MatrixClient.prototype.backupAllGroupSessions = function(version) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
return this._crypto.backupAllGroupSessions(version);
};
MatrixClient.prototype.isValidRecoveryKey = function(recoveryKey) {
try {
decodeRecoveryKey(recoveryKey);
return true;
} catch (e) {
return false;
}
};
MatrixClient.prototype.restoreKeyBackups = function(
recoveryKey, targetRoomId, targetSessionId, version,
) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
let totalKeyCount = 0;
let keys = [];
const path = this._makeKeyBackupPath(targetRoomId, targetSessionId, version);
// FIXME: see the FIXME in createKeyBackupVersion
const privkey = decodeRecoveryKey(recoveryKey);
const decryption = new global.Olm.PkDecryption();
try {
decryption.init_with_private_key(privkey);
} catch(e) {
decryption.free();
throw e;
}
return this._http.authedRequest(
undefined, "GET", path.path, path.queryData,
).then((res) => {
if (res.rooms) {
for (const [roomId, roomData] of Object.entries(res.rooms)) {
if (!roomData.sessions) continue;
totalKeyCount += Object.keys(roomData.sessions).length;
const roomKeys = keysFromRecoverySession(
roomData.sessions, decryption, roomId, roomKeys,
);
for (const k of roomKeys) {
k.room_id = roomId;
keys.push(k);
}
}
} else if (res.sessions) {
totalKeyCount = Object.keys(res.sessions).length;
keys = keysFromRecoverySession(
res.sessions, decryption, targetRoomId, keys,
);
} else {
totalKeyCount = 1;
try {
const key = keyFromRecoverySession(res, decryption);
key.room_id = targetRoomId;
key.session_id = targetSessionId;
keys.push(key);
} catch (e) {
console.log("Failed to decrypt session from backup");
}
}
return this.importRoomKeys(keys);
}).then(() => {
return {total: totalKeyCount, imported: keys.length};
}).finally(() => {
decryption.free();
});
};
MatrixClient.prototype.deleteKeysFromBackup = function(roomId, sessionId, version) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
const path = this._makeKeyBackupPath(roomId, sessionId, version);
return this._http.authedRequest(
undefined, "DELETE", path.path, path.queryData,
);
};
// Group ops // Group ops
// ========= // =========
// Operations on groups that come down the sync stream (ie. ones the // Operations on groups that come down the sync stream (ie. ones the
@@ -3738,6 +4065,24 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
* }); * });
*/ */
/**
* Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled()
* @event module:client~MatrixClient#"keyBackupStatus"
* @param {bool} enabled true if key backup has been enabled, otherwise false
* @example
* matrixClient.on("keyBackupStatus", function(enabled){
* if (enabled) {
* [...]
* }
* });
*/
/**
* Fires when we want to suggest to the user that they restore their megolm keys
* from backup or by cross-signing the device.
*
* @event module:client~MatrixClient#"crypto.suggestKeyRestore"
*/
// EventEmitter JSDocs // EventEmitter JSDocs

View File

@@ -71,6 +71,9 @@ export default class DeviceList {
// } // }
this._devices = {}; this._devices = {};
// map of identity keys to the user who owns it
this._userByIdentityKey = {};
// which users we are tracking device status for. // which users we are tracking device status for.
// userId -> TRACKING_STATUS_* // userId -> TRACKING_STATUS_*
this._deviceTrackingStatus = {}; // loaded from storage in load() this._deviceTrackingStatus = {}; // loaded from storage in load()
@@ -128,6 +131,19 @@ export default class DeviceList {
deviceData.trackingStatus : {}; deviceData.trackingStatus : {};
this._syncToken = deviceData ? deviceData.syncToken : null; this._syncToken = deviceData ? deviceData.syncToken : null;
} }
this._userByIdentityKey = {};
for (const user in this._devices) {
if (!this._devices.hasOwnProperty(user)) {
continue;
}
const userDevices = this._devices[user];
for (const device in userDevices) {
if (!userDevices.hasOwnProperty(device)) {
continue;
}
this._userByIdentityKey[userDevices[device].senderKey] = user;
}
}
}); });
}, },
); );
@@ -357,13 +373,24 @@ export default class DeviceList {
/** /**
* Find a device by curve25519 identity key * Find a device by curve25519 identity key
* *
* @param {string} userId owner of the device * @param {string} userId owner of the device (optional)
* @param {string} algorithm encryption algorithm * @param {string} algorithm encryption algorithm
* @param {string} senderKey curve25519 key to match * @param {string} senderKey curve25519 key to match
* *
* @return {module:crypto/deviceinfo?} * @return {module:crypto/deviceinfo?}
*/ */
getDeviceByIdentityKey(userId, algorithm, senderKey) { getDeviceByIdentityKey(userId, algorithm, senderKey) {
if (arguments.length === 2) {
// if userId is omitted, shift the other arguments, and look up the
// userid
senderKey = algorithm;
algorithm = userId;
userId = this._userByIdentityKey[senderKey];
if (!userId) {
return null;
}
}
if ( if (
algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.OLM_ALGORITHM &&
algorithm !== olmlib.MEGOLM_ALGORITHM algorithm !== olmlib.MEGOLM_ALGORITHM
@@ -409,6 +436,12 @@ export default class DeviceList {
*/ */
storeDevicesForUser(u, devs) { storeDevicesForUser(u, devs) {
this._devices[u] = devs; this._devices[u] = devs;
for (const device in devs) {
if (!devs.hasOwnProperty(device)) {
continue;
}
this._userByIdentityKey[devs[device].senderKey] = u;
}
this._dirty = true; this._dirty = true;
} }
@@ -526,6 +559,12 @@ export default class DeviceList {
*/ */
_setRawStoredDevicesForUser(userId, devices) { _setRawStoredDevicesForUser(userId, devices) {
this._devices[userId] = devices; this._devices[userId] = devices;
for (const device in devices) {
if (!devices.hasOwnProperty(device)) {
continue;
}
this._userByIdentityKey[devices[device].senderKey] = userId;
}
} }
/** /**

View File

@@ -17,17 +17,6 @@ limitations under the License.
import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
/**
* olm.js wrapper
*
* @module crypto/OlmDevice
*/
const Olm = global.Olm;
if (!Olm) {
throw new Error("global.Olm is not defined");
}
// The maximum size of an event is 65K, and we base64 the content, so this is a // The maximum size of an event is 65K, and we base64 the content, so this is a
// reasonable approximation to the biggest plaintext we can encrypt. // reasonable approximation to the biggest plaintext we can encrypt.
const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4; const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4;
@@ -91,6 +80,17 @@ function OlmDevice(sessionStore, cryptoStore) {
this.deviceEd25519Key = null; this.deviceEd25519Key = null;
this._maxOneTimeKeys = null; this._maxOneTimeKeys = null;
// track which of our other devices (if any) have cross-signed this device
// XXX: this should probably have a single source of truth in the /devices
// API store or whatever we use to track our self-signed devices.
this.crossSelfSigs = [];
// track whether we have already suggested to the user that they should
// restore their keys from backup or by cross-signing the device.
// We use this to avoid repeatedly emitting the suggestion event.
// XXX: persist this somewhere!
this.suggestedKeyRestore = false;
// we don't bother stashing outboundgroupsessions in the sessionstore - // we don't bother stashing outboundgroupsessions in the sessionstore -
// instead we keep them here. // instead we keep them here.
this._outboundGroupSessionStore = {}; this._outboundGroupSessionStore = {};
@@ -127,7 +127,7 @@ OlmDevice.prototype.init = async function() {
await this._migrateFromSessionStore(); await this._migrateFromSessionStore();
let e2eKeys; let e2eKeys;
const account = new Olm.Account(); const account = new global.Olm.Account();
try { try {
await _initialiseAccount( await _initialiseAccount(
this._sessionStore, this._cryptoStore, this._pickleKey, account, this._sessionStore, this._cryptoStore, this._pickleKey, account,
@@ -161,7 +161,7 @@ async function _initialiseAccount(sessionStore, cryptoStore, pickleKey, account)
* @return {array} The version of Olm. * @return {array} The version of Olm.
*/ */
OlmDevice.getOlmVersion = function() { OlmDevice.getOlmVersion = function() {
return Olm.get_library_version(); return global.Olm.get_library_version();
}; };
OlmDevice.prototype._migrateFromSessionStore = async function() { OlmDevice.prototype._migrateFromSessionStore = async function() {
@@ -268,7 +268,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
*/ */
OlmDevice.prototype._getAccount = function(txn, func) { OlmDevice.prototype._getAccount = function(txn, func) {
this._cryptoStore.getAccount(txn, (pickledAccount) => { this._cryptoStore.getAccount(txn, (pickledAccount) => {
const account = new Olm.Account(); const account = new global.Olm.Account();
try { try {
account.unpickle(this._pickleKey, pickledAccount); account.unpickle(this._pickleKey, pickledAccount);
func(account); func(account);
@@ -321,7 +321,7 @@ OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) {
* @private * @private
*/ */
OlmDevice.prototype._unpickleSession = function(pickledSession, func) { OlmDevice.prototype._unpickleSession = function(pickledSession, func) {
const session = new Olm.Session(); const session = new global.Olm.Session();
try { try {
session.unpickle(this._pickleKey, pickledSession); session.unpickle(this._pickleKey, pickledSession);
func(session); func(session);
@@ -354,7 +354,7 @@ OlmDevice.prototype._saveSession = function(deviceKey, session, txn) {
* @private * @private
*/ */
OlmDevice.prototype._getUtility = function(func) { OlmDevice.prototype._getUtility = function(func) {
const utility = new Olm.Utility(); const utility = new global.Olm.Utility();
try { try {
return func(utility); return func(utility);
} finally { } finally {
@@ -466,7 +466,7 @@ OlmDevice.prototype.createOutboundSession = async function(
], ],
(txn) => { (txn) => {
this._getAccount(txn, (account) => { this._getAccount(txn, (account) => {
const session = new Olm.Session(); const session = new global.Olm.Session();
try { try {
session.create_outbound(account, theirIdentityKey, theirOneTimeKey); session.create_outbound(account, theirIdentityKey, theirOneTimeKey);
newSessionId = session.session_id(); newSessionId = session.session_id();
@@ -510,7 +510,7 @@ OlmDevice.prototype.createInboundSession = async function(
], ],
(txn) => { (txn) => {
this._getAccount(txn, (account) => { this._getAccount(txn, (account) => {
const session = new Olm.Session(); const session = new global.Olm.Session();
try { try {
session.create_inbound_from( session.create_inbound_from(
account, theirDeviceIdentityKey, ciphertext, account, theirDeviceIdentityKey, ciphertext,
@@ -728,7 +728,7 @@ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) {
throw new Error("Unknown outbound group session " + sessionId); throw new Error("Unknown outbound group session " + sessionId);
} }
const session = new Olm.OutboundGroupSession(); const session = new global.Olm.OutboundGroupSession();
try { try {
session.unpickle(this._pickleKey, pickled); session.unpickle(this._pickleKey, pickled);
return func(session); return func(session);
@@ -744,7 +744,7 @@ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) {
* @return {string} sessionId for the outbound session. * @return {string} sessionId for the outbound session.
*/ */
OlmDevice.prototype.createOutboundGroupSession = function() { OlmDevice.prototype.createOutboundGroupSession = function() {
const session = new Olm.OutboundGroupSession(); const session = new global.Olm.OutboundGroupSession();
try { try {
session.create(); session.create();
this._saveOutboundGroupSession(session); this._saveOutboundGroupSession(session);
@@ -816,7 +816,7 @@ OlmDevice.prototype.getOutboundGroupSessionKey = function(sessionId) {
* @return {*} result of func * @return {*} result of func
*/ */
OlmDevice.prototype._unpickleInboundGroupSession = function(sessionData, func) { OlmDevice.prototype._unpickleInboundGroupSession = function(sessionData, func) {
const session = new Olm.InboundGroupSession(); const session = new global.Olm.InboundGroupSession();
try { try {
session.unpickle(this._pickleKey, sessionData.session); session.unpickle(this._pickleKey, sessionData.session);
return func(session); return func(session);
@@ -897,7 +897,7 @@ OlmDevice.prototype.addInboundGroupSession = async function(
} }
// new session. // new session.
const session = new Olm.InboundGroupSession(); const session = new global.Olm.InboundGroupSession();
try { try {
if (exportFormat) { if (exportFormat) {
session.import_session(sessionKey); session.import_session(sessionKey);
@@ -1119,6 +1119,7 @@ OlmDevice.prototype.exportInboundGroupSession = function(
"session_id": sessionId, "session_id": sessionId,
"session_key": session.export_session(messageIndex), "session_key": session.export_session(messageIndex),
"forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [], "forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [],
"first_known_index": session.first_known_index(),
}; };
}); });
}; };

View File

@@ -263,6 +263,14 @@ MegolmEncryption.prototype._prepareNewSession = async function() {
key.key, {ed25519: this._olmDevice.deviceEd25519Key}, key.key, {ed25519: this._olmDevice.deviceEd25519Key},
); );
if (this._crypto.backupInfo) {
// don't wait for it to complete
this._crypto.backupGroupSession(
this._roomId, this._olmDevice.deviceCurve25519Key, [],
sessionId, key.key,
);
}
return new OutboundSessionInfo(sessionId); return new OutboundSessionInfo(sessionId);
}; };
@@ -824,7 +832,7 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
} }
console.log(`Adding key for megolm session ${senderKey}|${sessionId}`); console.log(`Adding key for megolm session ${senderKey}|${sessionId}`);
this._olmDevice.addInboundGroupSession( return this._olmDevice.addInboundGroupSession(
content.room_id, senderKey, forwardingKeyChain, sessionId, content.room_id, senderKey, forwardingKeyChain, sessionId,
content.session_key, keysClaimed, content.session_key, keysClaimed,
exportFormat, exportFormat,
@@ -839,6 +847,15 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
// have another go at decrypting events sent with this session. // have another go at decrypting events sent with this session.
this._retryDecryption(senderKey, sessionId); this._retryDecryption(senderKey, sessionId);
}).then(() => {
if (this._crypto.backupInfo) {
// don't wait for it to complete
this._crypto.backupGroupSession(
content.room_id, senderKey, forwardingKeyChain,
content.session_id, content.session_key, keysClaimed,
exportFormat,
);
}
}).catch((e) => { }).catch((e) => {
console.error(`Error handling m.room_key_event: ${e}`); console.error(`Error handling m.room_key_event: ${e}`);
}); });
@@ -956,6 +973,18 @@ MegolmDecryption.prototype.importRoomKey = function(session) {
session.sender_claimed_keys, session.sender_claimed_keys,
true, true,
).then(() => { ).then(() => {
if (this._crypto.backupInfo) {
// don't wait for it to complete
this._crypto.backupGroupSession(
session.room_id,
session.sender_key,
session.forwarding_curve25519_key_chain,
session.session_id,
session.session_key,
session.sender_claimed_keys,
true,
);
}
// have another go at decrypting events sent with this session. // have another go at decrypting events sent with this session.
this._retryDecryption(session.sender_key, session.session_id); this._retryDecryption(session.sender_key, session.session_id);
}); });

View File

@@ -36,6 +36,10 @@ const DeviceList = require('./DeviceList').default;
import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager'; import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager';
import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
export function isCryptoAvailable() {
return Boolean(global.Olm);
}
/** /**
* Cryptography bits * Cryptography bits
* *
@@ -62,7 +66,7 @@ import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
* *
* @param {RoomList} roomList An initialised RoomList object * @param {RoomList} roomList An initialised RoomList object
*/ */
function Crypto(baseApis, sessionStore, userId, deviceId, export default function Crypto(baseApis, sessionStore, userId, deviceId,
clientStore, cryptoStore, roomList) { clientStore, cryptoStore, roomList) {
this._baseApis = baseApis; this._baseApis = baseApis;
this._sessionStore = sessionStore; this._sessionStore = sessionStore;
@@ -72,6 +76,14 @@ function Crypto(baseApis, sessionStore, userId, deviceId,
this._cryptoStore = cryptoStore; this._cryptoStore = cryptoStore;
this._roomList = roomList; this._roomList = roomList;
// track whether this device's megolm keys are being backed up incrementally
// to the server or not.
// XXX: this should probably have a single source of truth from OlmAccount
this.backupInfo = null; // The info dict from /room_keys/version
this.backupKey = null; // The encryption key object
this._checkedForBackup = false; // Have we checked the server for a backup we can use?
this._sendingBackups = false; // Are we currently sending backups?
this._olmDevice = new OlmDevice(sessionStore, cryptoStore); this._olmDevice = new OlmDevice(sessionStore, cryptoStore);
this._deviceList = new DeviceList( this._deviceList = new DeviceList(
baseApis, cryptoStore, sessionStore, this._olmDevice, baseApis, cryptoStore, sessionStore, this._olmDevice,
@@ -124,6 +136,10 @@ utils.inherits(Crypto, EventEmitter);
* Returns a promise which resolves once the crypto module is ready for use. * Returns a promise which resolves once the crypto module is ready for use.
*/ */
Crypto.prototype.init = async function() { Crypto.prototype.init = async function() {
// Olm is just an object with a .then, not a fully-fledged promise, so
// pass it into bluebird to make it a proper promise.
await global.Olm.init();
const sessionStoreHasAccount = Boolean(this._sessionStore.getEndToEndAccount()); const sessionStoreHasAccount = Boolean(this._sessionStore.getEndToEndAccount());
let cryptoStoreHasAccount; let cryptoStoreHasAccount;
await this._cryptoStore.doTxn( await this._cryptoStore.doTxn(
@@ -174,6 +190,115 @@ Crypto.prototype.init = async function() {
); );
this._deviceList.saveIfDirty(); this._deviceList.saveIfDirty();
} }
this._checkAndStartKeyBackup();
};
/**
* Check the server for an active key backup and
* if one is present and has a valid signature from
* one of the user's verified devices, start backing up
* to it.
*/
Crypto.prototype._checkAndStartKeyBackup = async function() {
console.log("Checking key backup status...");
let backupInfo;
try {
backupInfo = await this._baseApis.getKeyBackupVersion();
} catch (e) {
console.log("Error checking for active key backup", e);
if (Number.isFinite(e.httpStatus) && e.httpStatus / 100 === 4) {
// well that's told us. we won't try again.
this._checkedForBackup = true;
}
return;
}
this._checkedForBackup = true;
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (trustInfo.usable && !this.backupInfo) {
console.log("Found usable key backup: enabling key backups");
this._baseApis.enableKeyBackup(backupInfo);
} else if (!trustInfo.usable && this.backupInfo) {
console.log("No usable key backup: disabling key backup");
this._baseApis.disableKeyBackup();
} else if (!trustInfo.usable && !this.backupInfo) {
console.log("No usable key backup: not enabling key backup");
}
};
/**
* Forces a re-check of the key backup and enables/disables it
* as appropriate
*
* @param {object} backupInfo Backup info from /room_keys/version endpoint
*/
Crypto.prototype.checkKeyBackup = async function(backupInfo) {
this._checkedForBackup = false;
await this._checkAndStartKeyBackup();
};
/**
* @param {object} backupInfo key backup info dict from /room_keys/version
* @return {object} {
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
* sigs: [
* valid: [bool],
* device: [DeviceInfo],
* ]
* }
*/
Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) {
const ret = {
usable: false,
sigs: [],
};
if (
!backupInfo ||
!backupInfo.algorithm ||
!backupInfo.auth_data ||
!backupInfo.auth_data.public_key ||
!backupInfo.auth_data.signatures
) {
console.log("Key backup is absent or missing required data");
return ret;
}
const mySigs = backupInfo.auth_data.signatures[this._userId];
if (!mySigs || mySigs.length === 0) {
console.log("Ignoring key backup because it lacks any signatures from this user");
return ret;
}
for (const keyId of Object.keys(mySigs)) {
const device = this._deviceList.getStoredDevice(
this._userId, keyId.split(':')[1], // XXX: is this how we're supposed to get the device ID?
);
if (!device) {
console.log("Ignoring signature from unknown key " + keyId);
continue;
}
const sigInfo = { device };
try {
await olmlib.verifySignature(
this._olmDevice,
backupInfo.auth_data,
this._userId,
device.deviceId,
device.getFingerprint(),
);
sigInfo.valid = true;
} catch (e) {
console.log("Bad signature from device " + device.deviceId, e);
sigInfo.valid = false;
}
ret.sigs.push(sigInfo);
}
ret.usable = ret.sigs.some((s) => s.valid && s.device.isVerified());
return ret;
}; };
/** /**
@@ -815,6 +940,7 @@ Crypto.prototype.exportRoomKeys = async function() {
const sess = this._olmDevice.exportInboundGroupSession( const sess = this._olmDevice.exportInboundGroupSession(
s.senderKey, s.sessionId, s.sessionData, s.senderKey, s.sessionId, s.sessionData,
); );
delete sess.first_known_index;
sess.algorithm = olmlib.MEGOLM_ALGORITHM; sess.algorithm = olmlib.MEGOLM_ALGORITHM;
exportedSessions.push(sess); exportedSessions.push(sess);
}); });
@@ -843,6 +969,126 @@ Crypto.prototype.importRoomKeys = function(keys) {
}, },
); );
}; };
Crypto.prototype._maybeSendKeyBackup = async function() {
if (!this._sendingBackups) {
this._sendingBackups = true;
try {
// wait between 0 and 10 seconds, to avoid backup requests from
// different clients hitting the server all at the same time when a
// new key is sent
await new Promise((resolve, reject) => {
setTimeout(resolve, Math.random() * 10000);
});
let numFailures = 0; // number of consecutive failures
while (1) {
if (!this.backupKey) {
return;
}
// FIXME: figure out what limit is reasonable
const sessions = await this._cryptoStore.getSessionsNeedingBackup(10);
if (!sessions.length) {
return;
}
const data = {};
for (const session of sessions) {
const roomId = session.sessionData.room_id;
if (data[roomId] === undefined) {
data[roomId] = {sessions: {}};
}
const sessionData = await this._olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = olmlib.MEGOLM_ALGORITHM;
delete sessionData.session_id;
delete sessionData.room_id;
const firstKnownIndex = sessionData.first_known_index;
delete sessionData.first_known_index;
const encrypted = this.backupKey.encrypt(JSON.stringify(sessionData));
const forwardedCount =
(sessionData.forwardingCurve25519KeyChain || []).length;
const device = this._deviceList.getDeviceByIdentityKey(
olmlib.MEGOLM_ALGORITHM, session.senderKey,
);
data[roomId]['sessions'][session.sessionId] = {
first_message_index: firstKnownIndex,
forwarded_count: forwardedCount,
is_verified: !!(device && device.isVerified()),
session_data: encrypted,
};
}
try {
await this._baseApis.sendKeyBackup(
undefined, undefined, this.backupInfo.version,
{rooms: data},
);
numFailures = 0;
await this._cryptoStore.unmarkSessionsNeedingBackup(sessions);
} catch (err) {
numFailures++;
console.log("send failed", err);
if (err.httpStatus === 400
|| err.httpStatus === 403
|| err.httpStatus === 401) {
// retrying probably won't help much, so we should give up
// FIXME: disable backups?
return;
}
}
if (numFailures) {
// exponential backoff if we have failures
await new Promise((resolve, reject) => {
setTimeout(
resolve,
1000 * Math.pow(2, Math.min(numFailures - 1, 4)),
);
});
}
}
} finally {
this._sendingBackups = false;
}
}
};
Crypto.prototype.backupGroupSession = async function(
roomId, senderKey, forwardingCurve25519KeyChain,
sessionId, sessionKey, keysClaimed,
exportFormat,
) {
if (!this.backupInfo) {
throw new Error("Key backups are not enabled");
}
await this._cryptoStore.markSessionsNeedingBackup([{
senderKey: senderKey,
sessionId: sessionId,
}]);
await this._maybeSendKeyBackup();
};
Crypto.prototype.backupAllGroupSessions = async function(version) {
await this._cryptoStore.doTxn(
'readwrite',
[IndexedDBCryptoStore.STORE_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP],
(txn) => {
this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
if (session !== null) {
this._cryptoStore.markSessionsNeedingBackup([session], txn);
}
});
},
);
await this._maybeSendKeyBackup();
};
/* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307 /* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307
/** /**
* Encrypt an event according to the configuration of the room. * Encrypt an event according to the configuration of the room.
@@ -1150,6 +1396,12 @@ Crypto.prototype._onRoomKeyEvent = function(event) {
return; return;
} }
if (!this._checkedForBackup) {
// don't bother awaiting on this - the important thing is that we retry if we
// haven't managed to check before
this._checkAndStartKeyBackup();
}
const alg = this._getRoomDecryptor(content.room_id, content.algorithm); const alg = this._getRoomDecryptor(content.room_id, content.algorithm);
alg.onRoomKeyEvent(event); alg.onRoomKeyEvent(event);
}; };
@@ -1518,6 +1770,3 @@ class IncomingRoomKeyRequestCancellation {
* @event module:client~MatrixClient#"crypto.warning" * @event module:client~MatrixClient#"crypto.warning"
* @param {string} type One of the strings listed above * @param {string} type One of the strings listed above
*/ */
/** */
module.exports = Crypto;

View File

@@ -35,6 +35,11 @@ module.exports.OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
*/ */
module.exports.MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; module.exports.MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
/**
* matrix algorithm tag for megolm backups
*/
module.exports.MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2";
/** /**
* Encrypt an event payload for an Olm device * Encrypt an event payload for an Olm device

67
src/crypto/recoverykey.js Normal file
View File

@@ -0,0 +1,67 @@
/*
Copyright 2018 New Vector 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.
*/
import bs58 from 'bs58';
// picked arbitrarily but to try & avoid clashing with any bitcoin ones
// (also base58 encoded, albeit with a lot of hashing)
const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01];
export function encodeRecoveryKey(key) {
const buf = new Uint8Array(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1);
buf.set(OLM_RECOVERY_KEY_PREFIX, 0);
buf.set(key, OLM_RECOVERY_KEY_PREFIX.length);
let parity = 0;
for (let i = 0; i < buf.length - 1; ++i) {
parity ^= buf[i];
}
buf[buf.length - 1] = parity;
const base58key = bs58.encode(buf);
return base58key.match(/.{1,4}/g).join(" ");
}
export function decodeRecoveryKey(recoverykey) {
const result = bs58.decode(recoverykey.replace(/ /g, ''));
let parity = 0;
for (const b of result) {
parity ^= b;
}
if (parity !== 0) {
throw new Error("Incorrect parity");
}
for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) {
if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) {
throw new Error("Incorrect prefix");
}
}
if (
result.length !==
OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1
) {
throw new Error("Incorrect length");
}
return result.slice(
OLM_RECOVERY_KEY_PREFIX.length,
OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH,
);
}

View File

@@ -460,6 +460,71 @@ export class Backend {
}; };
} }
// session backups
getSessionsNeedingBackup(limit) {
return new Promise((resolve, reject) => {
const sessions = [];
const txn = this._db.transaction(
["sessions_needing_backup", "inbound_group_sessions"],
"readonly",
);
txn.onerror = reject;
txn.oncomplete = function() {
resolve(sessions);
};
const objectStore = txn.objectStore("sessions_needing_backup");
const sessionStore = txn.objectStore("inbound_group_sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function() {
const cursor = getReq.result;
if (cursor) {
const sessionGetReq = sessionStore.get(cursor.key);
sessionGetReq.onsuccess = function() {
sessions.push({
senderKey: sessionGetReq.result.senderCurve25519Key,
sessionId: sessionGetReq.result.sessionId,
sessionData: sessionGetReq.result.session,
});
};
if (!limit || sessions.length < limit) {
cursor.continue();
}
}
};
});
}
unmarkSessionsNeedingBackup(sessions) {
const txn = this._db.transaction("sessions_needing_backup", "readwrite");
const objectStore = txn.objectStore("sessions_needing_backup");
return Promise.all(sessions.map((session) => {
return new Promise((resolve, reject) => {
const req = objectStore.delete([session.senderKey, session.sessionId]);
req.onsuccess = resolve;
req.onerror = reject;
});
}));
}
markSessionsNeedingBackup(sessions, txn) {
if (!txn) {
txn = this._db.transaction("sessions_needing_backup", "readwrite");
}
const objectStore = txn.objectStore("sessions_needing_backup");
return Promise.all(sessions.map((session) => {
return new Promise((resolve, reject) => {
const req = objectStore.put({
senderCurve25519Key: session.senderKey,
sessionId: session.sessionId,
});
req.onsuccess = resolve;
req.onerror = reject;
});
}));
}
doTxn(mode, stores, func) { doTxn(mode, stores, func) {
const txn = this._db.transaction(stores, mode); const txn = this._db.transaction(stores, mode);
const promise = promiseifyTxn(txn); const promise = promiseifyTxn(txn);
@@ -498,6 +563,11 @@ export function upgradeDatabase(db, oldVersion) {
if (oldVersion < 6) { if (oldVersion < 6) {
db.createObjectStore("rooms"); db.createObjectStore("rooms");
} }
if (oldVersion < 7) {
db.createObjectStore("sessions_needing_backup", {
keyPath: ["senderCurve25519Key", "sessionId"],
});
}
// Expand as needed. // Expand as needed.
} }

View File

@@ -420,6 +420,43 @@ export default class IndexedDBCryptoStore {
this._backendPromise.value().getEndToEndRooms(txn, func); this._backendPromise.value().getEndToEndRooms(txn, func);
} }
// session backups
/**
* Get the inbound group sessions that need to be backed up.
* @param {integer} limit The maximum number of sessions to retrieve. 0
* for no limit.
* @returns {Promise} resolves to an array of inbound group sessions
*/
getSessionsNeedingBackup(limit) {
return this._connect().then((backend) => {
return backend.getSessionsNeedingBackup(limit);
});
}
/**
* Unmark sessions as needing to be backed up.
* @param {[object]} sessions The sessions that need to be backed up.
* @returns {Promise} resolves when the sessions are unmarked
*/
unmarkSessionsNeedingBackup(sessions) {
return this._connect().then((backend) => {
return backend.unmarkSessionsNeedingBackup(sessions);
});
}
/**
* Mark sessions as needing to be backed up.
* @param {[object]} sessions The sessions that need to be backed up.
* @param {*} txn An active transaction. See doTxn(). (optional)
* @returns {Promise} resolves when the sessions are marked
*/
markSessionsNeedingBackup(sessions, txn) {
return this._connect().then((backend) => {
return backend.markSessionsNeedingBackup(sessions, txn);
});
}
/** /**
* Perform a transaction on the crypto store. Any store methods * Perform a transaction on the crypto store. Any store methods
* that require a transaction (txn) object to be passed in may * that require a transaction (txn) object to be passed in may
@@ -453,3 +490,4 @@ IndexedDBCryptoStore.STORE_SESSIONS = 'sessions';
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions';
IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data';
IndexedDBCryptoStore.STORE_ROOMS = 'rooms'; IndexedDBCryptoStore.STORE_ROOMS = 'rooms';
IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup';

View File

@@ -32,6 +32,7 @@ const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; const KEY_DEVICE_DATA = E2E_PREFIX + "device_data";
const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/";
const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/"; const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/";
const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup";
function keyEndToEndSessions(deviceKey) { function keyEndToEndSessions(deviceKey) {
return E2E_PREFIX + "sessions/" + deviceKey; return E2E_PREFIX + "sessions/" + deviceKey;
@@ -165,6 +166,57 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore {
func(result); func(result);
} }
getSessionsNeedingBackup(limit) {
const sessionsNeedingBackup
= getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
const sessions = [];
for (const session in sessionsNeedingBackup) {
if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) {
const senderKey = session.substr(0, 43);
const sessionId = session.substr(44);
this.getEndToEndInboundGroupSession(
senderKey, sessionId, null,
(sessionData) => {
sessions.push({
senderKey: senderKey,
sessionId: sessionId,
sessionData: sessionData,
});
},
);
if (limit && session.length >= limit) {
break;
}
}
}
return Promise.resolve(sessions);
}
unmarkSessionsNeedingBackup(sessions) {
const sessionsNeedingBackup
= getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
for (const session of sessions) {
delete sessionsNeedingBackup[session.senderKey + '/' + session.sessionId];
}
setJsonItem(
this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup,
);
return Promise.resolve();
}
markSessionsNeedingBackup(sessions) {
const sessionsNeedingBackup
= getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
for (const session of sessions) {
sessionsNeedingBackup[session.senderKey + '/' + session.sessionId] = true;
}
setJsonItem(
this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup,
);
return Promise.resolve();
}
/** /**
* Delete all data from this store. * Delete all data from this store.
* *

View File

@@ -41,6 +41,8 @@ export default class MemoryCryptoStore {
this._deviceData = null; this._deviceData = null;
// roomId -> Opaque roomInfo object // roomId -> Opaque roomInfo object
this._rooms = {}; this._rooms = {};
// Set of {senderCurve25519Key+'/'+sessionId}
this._sessionsNeedingBackup = {};
} }
/** /**
@@ -295,6 +297,41 @@ export default class MemoryCryptoStore {
func(this._rooms); func(this._rooms);
} }
getSessionsNeedingBackup(limit) {
const sessions = [];
for (const session in this._sessionsNeedingBackup) {
if (this._inboundGroupSessions[session]) {
sessions.push({
senderKey: session.substr(0, 43),
sessionId: session.substr(44),
sessionData: this._inboundGroupSessions[session],
});
if (limit && session.length >= limit) {
break;
}
}
}
return Promise.resolve(sessions);
}
unmarkSessionsNeedingBackup(sessions) {
for (const session of sessions) {
const sessionKey = session.senderKey + '/' + session.sessionId;
delete this._sessionsNeedingBackup[sessionKey];
}
return Promise.resolve();
}
markSessionsNeedingBackup(sessions) {
for (const session of sessions) {
const sessionKey = session.senderKey + '/' + session.sessionId;
this._sessionsNeedingBackup[sessionKey] = true;
}
return Promise.resolve();
}
// Session key backups
doTxn(mode, stores, func) { doTxn(mode, stores, func) {
return Promise.resolve(func(null)); return Promise.resolve(func(null));
} }

View File

@@ -5,7 +5,7 @@ set -ex
npm run lint npm run lint
# install Olm so that we can run the crypto tests. # install Olm so that we can run the crypto tests.
npm install https://matrix.org/packages/npm/olm/olm-2.2.2.tgz npm install https://matrix.org/packages/npm/olm/olm-2.3.0.tgz
npm run test npm run test