You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-18 05:42:00 +03:00
Start integrating end-to-end into the matrix-client. Add a storage class to store end-to-end sessions. Implement the one-time key upload API, and start sketching out the encryption and decryption functions
This commit is contained in:
297
lib/client.js
297
lib/client.js
@@ -17,6 +17,9 @@ var Room = require("./models/room");
|
||||
var User = require("./models/user");
|
||||
var utils = require("./utils");
|
||||
|
||||
// TODO: package this somewhere separate.
|
||||
var Olm = require("./olm");
|
||||
|
||||
// TODO:
|
||||
// Internal: rate limiting
|
||||
|
||||
@@ -44,11 +47,48 @@ var utils = require("./utils");
|
||||
*/
|
||||
function MatrixClient(opts) {
|
||||
utils.checkObjectHasKeys(opts, ["baseUrl", "request"]);
|
||||
utils.checkObjectHasNoAdditionalKeys(opts,
|
||||
["baseUrl", "request", "accessToken", "userId", "store", "scheduler"]
|
||||
);
|
||||
utils.checkObjectHasNoAdditionalKeys(opts, [
|
||||
"baseUrl", "request", "accessToken", "userId", "store", "scheduler",
|
||||
"sessionStore", "deviceId"
|
||||
]);
|
||||
|
||||
this.store = opts.store || new StubStore();
|
||||
this.sessionStore = opts.sessionStore || null;
|
||||
this.accountKey = "DEFAULT_KEY";
|
||||
this.deviceId = opts.deviceId;
|
||||
if (this.sessionStore !== null) {
|
||||
var e2eAccount = this.sessionStore.getEndToEndAccount();
|
||||
var account = new Olm.Account();
|
||||
try {
|
||||
if (e2eAccount == null) {
|
||||
account.create();
|
||||
} else {
|
||||
account.unpickle(this.accountKey, e2eAccount);
|
||||
}
|
||||
var e2eKeys = JSON.parse(account.identity_keys());
|
||||
var json = '{"algorithms":["m.olm.v1.curve25519-aes-sha2"]';
|
||||
json += ',"device_id":"' + this.deviceId + '"';
|
||||
json += ',"keys":';
|
||||
json += '{"ed25519:' + this.deviceId + '":';
|
||||
json += JSON.stringify(e2eKeys.ed25519);
|
||||
json += ',"curve25519:' + this.deviceId + '":';
|
||||
json += JSON.stringify(e2eKeys.curve25519);
|
||||
json += '}';
|
||||
json += ',"user_id":' + JSON.stringify(opts.userId);
|
||||
json += '}';
|
||||
var signature = account.sign(json);
|
||||
this.deviceKeys = JSON.parse(json);
|
||||
var signatures = {};
|
||||
signatures[opts.userId] = {};
|
||||
signatures[opts.userId]["ed25519:" + this.deviceId] = signature;
|
||||
this.deviceKeys.signatures = signatures;
|
||||
this.deviceCurve25519Key = e2eKeys["curve25519"];
|
||||
var pickled = account.pickle(this.accountKey);
|
||||
this.sessionStore.storeEndToEndAccount(pickled);
|
||||
} finally {
|
||||
account.free();
|
||||
}
|
||||
}
|
||||
this.scheduler = opts.scheduler;
|
||||
if (this.scheduler) {
|
||||
var self = this;
|
||||
@@ -76,6 +116,61 @@ function MatrixClient(opts) {
|
||||
}
|
||||
utils.inherits(MatrixClient, EventEmitter);
|
||||
|
||||
MatrixClient.prototype.uploadKeys = function(deferred) {
|
||||
var first_time = deferred === undefined;
|
||||
deferred = deferred || q.defer();
|
||||
var path = "/keys/upload/" + this.deviceId;
|
||||
var pickled = this.sessionStore.getEndToEndAccount();
|
||||
var account = new Olm.Account();
|
||||
try {
|
||||
account.unpickle(this.accountKey, pickled);
|
||||
var oneTimeKeys = JSON.parse(account.one_time_keys());
|
||||
var maxOneTimeKeys = account.max_number_of_one_time_keys();
|
||||
} finally {
|
||||
account.free();
|
||||
}
|
||||
var oneTimeJson = {};
|
||||
|
||||
for (var keyId in oneTimeKeys.curve25519) {
|
||||
oneTimeJson["curve25519:" + keyId] = oneTimeKeys.curve25519[keyId];
|
||||
}
|
||||
var content = {
|
||||
device_keys: this.deviceKeys,
|
||||
one_time_keys: oneTimeJson
|
||||
};
|
||||
var self = this;
|
||||
this._http.authedRequestWithPrefix(
|
||||
undefined, "POST", path, undefined, content, httpApi.PREFIX_V2_ALPHA
|
||||
).then(function(res) {
|
||||
var keyLimit = Math.floor(maxOneTimeKeys / 2);
|
||||
var keyCount = res.one_time_key_counts.curve25519 || 0;
|
||||
var generateKeys = (keyCount < keyLimit);
|
||||
var pickled = self.sessionStore.getEndToEndAccount();
|
||||
|
||||
var account = new Olm.Account();
|
||||
try {
|
||||
account.unpickle(self.accountKey, pickled);
|
||||
account.mark_keys_as_published();
|
||||
if (generateKeys) {
|
||||
account.generate_one_time_keys(keyLimit - keyCount);
|
||||
}
|
||||
pickled = account.pickle(self.accountKey);
|
||||
self.sessionStore.storeEndToEndAccount(pickled);
|
||||
} finally {
|
||||
account.free();
|
||||
}
|
||||
if (generateKeys && first_time) {
|
||||
self.uploadKeys(deferred);
|
||||
} else {
|
||||
deferred.resolve();
|
||||
}
|
||||
});
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the room for the given room ID.
|
||||
* @param {string} roomId The room ID
|
||||
@@ -267,6 +362,15 @@ MatrixClient.prototype.sendStateEvent = function(roomId, eventType, content, sta
|
||||
*/
|
||||
MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId,
|
||||
callback) {
|
||||
if (eventType === "m.room.message" && this.sessionStore) {
|
||||
var e2eRoomInfo = this.sessionStore.getEndToEndRoom(roomId);
|
||||
if (e2eRoomInfo) {
|
||||
return _sendEncryptedMessage(
|
||||
client, roomId, e2eRoomInfo, content, txnId, callback
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (utils.isFunction(txnId)) { callback = txnId; txnId = undefined; }
|
||||
if (!txnId) {
|
||||
txnId = "m" + new Date().getTime();
|
||||
@@ -295,6 +399,164 @@ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId,
|
||||
return _sendEvent(this, room, localEvent, callback);
|
||||
};
|
||||
|
||||
function _sendEncryptedMessage(client, roomId, e2eRoomInfo, eventType, content,
|
||||
txnId, callback) {
|
||||
if (!client.sessionStore) {
|
||||
throw new Error(
|
||||
"Client must have an end-to-end session store to encrypt messages"
|
||||
);
|
||||
}
|
||||
|
||||
if (e2eRoomInfo.algorithm === "m.olm.v1.curve25519-aes-sha2") {
|
||||
var participantKeys = [];
|
||||
for (var i = 0; i < e2eRoomInfo.participants.length; ++i) {
|
||||
var userId = e2eRoomInfo.participants[i];
|
||||
var userCiphertext = {};
|
||||
devices = client.sessionStore.getEndToEndDevicesForUser(userId);
|
||||
for (var deviceId in devices) {
|
||||
var keys = devices[deviceId];
|
||||
for (keyId in keys.keys) {
|
||||
if (keyId.startsWith("curve25519")) {
|
||||
participantKeys.push(keys.keys[keyId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
participantKeys.sort();
|
||||
var participantHash = ""; // Olm.sha256(participantKeys.join());
|
||||
var payloadJson = {
|
||||
roomId: roomId,
|
||||
type: eventType,
|
||||
fingerprint: participantHash,
|
||||
sender_device: client.deviceId,
|
||||
content: content
|
||||
};
|
||||
var ciphertext = {};
|
||||
var payloadString = JSON.stringify(payloadJson);
|
||||
for (var i = 0; i < participantKeys.length(); ++i) {
|
||||
var deviceKey = participantKeys[i];
|
||||
var sessions = client.sessionStore.getEndToEndSessions(
|
||||
deviceKey
|
||||
);
|
||||
var sessionIds = [];
|
||||
for (sessionId in sessions) {
|
||||
sessionIds.push(sessionId);
|
||||
}
|
||||
// Use the session with the lowest ID.
|
||||
sessionIds.sort()
|
||||
if (sessionIds.length == 0) {
|
||||
// If we don't have a session for a device then
|
||||
// we can't encrypt a message for it.
|
||||
continue;
|
||||
}
|
||||
var sessionId = sessionIds[0];
|
||||
var session = new Olm.Session();
|
||||
try {
|
||||
session.unpickle(client.accountKey, sessions[sessionId]);
|
||||
ciphertext[deviceKey] = session.encrypt(payloadString);
|
||||
var pickled = session.pickle(client.accountKey);
|
||||
client.sessionStore.storeEndToEndSession(
|
||||
userId, deviceId, sessionId, pickled
|
||||
);
|
||||
} finally {
|
||||
session.free();
|
||||
}
|
||||
}
|
||||
var encryptedContent = {
|
||||
algorithm: e2eRoomInfo.algorithm,
|
||||
sender_key: client.deviceCurve25519Key,
|
||||
ciphertext: ciphertext
|
||||
};
|
||||
return client.sendEvent(
|
||||
roomId, "m.room.encrypted", encryptedContent, txnId, callback
|
||||
);
|
||||
} else {
|
||||
throw new Error("Unknown end-to-end algorithm");
|
||||
}
|
||||
}
|
||||
|
||||
function _decryptMessage(client, event) {
|
||||
var content = event.getContent();
|
||||
if (content.algorithm === "m.olm.v1.curve25519-aes-sha2") {
|
||||
var sender = event.getSender();
|
||||
var deviceKey = content.sender_key;
|
||||
if (!client.deviceCurve25519Key in content.ciphertext) {
|
||||
return _badEncryptedMessage(event, "Not included in recipients");
|
||||
}
|
||||
var message = content.ciphertext[client.deviceCurve25519Key];
|
||||
var sessions = client.sessionStore.getEndToEndSessions(deviceKey);
|
||||
var payloadString = null;
|
||||
var foundSession = false;
|
||||
for (sessionId in sessions) {
|
||||
var session = new Olm.Session();
|
||||
try {
|
||||
session.unpickle(client.accountKey, sessions[sessionId]);
|
||||
if (message.type == 0 && session.matches(message.body)) {
|
||||
foundSession = true;
|
||||
}
|
||||
payloadString = session.decrypt(message.type, message.body);
|
||||
var pickled = session.pickle(client.accountKey);
|
||||
client.sessionStore.storeEndToEndSession(
|
||||
deviceKey, sessionId, pickled
|
||||
);
|
||||
} catch(e) {
|
||||
// Failed to decrypt with an existing session.
|
||||
} finally {
|
||||
session.free();
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type == 0 && !foundSession && payloadString !== null) {
|
||||
var account = Olm.Account();
|
||||
var session = Olm.Session();
|
||||
try {
|
||||
var account_data = client.sessionStore.getEndToEndAccount();
|
||||
account.unpickle(client.accountKey, account_data);
|
||||
session.create_inbound_from(account, deviceKey, message.body);
|
||||
payloadString = session.decrypt(message.type, message.body);
|
||||
account.remove_one_time_keys(session);
|
||||
var pickledSession = session.pickle(client.accountKey);
|
||||
var pickledAccount = account.pickle(client.accountKey);
|
||||
var sessionId = session.session_id();
|
||||
client.sessionStore.storeEndToEndSession(
|
||||
deviceKey, sessionId, pickledSession
|
||||
);
|
||||
client.sessionStore.storeEndToEndAccount(pickledAccount);
|
||||
} catch(e) {
|
||||
// Failed to decrypt with a new session.
|
||||
} finally {
|
||||
session.free();
|
||||
account.free();
|
||||
}
|
||||
}
|
||||
|
||||
if (payloadString !== null) {
|
||||
var payload = JSON.parse(payloadString);
|
||||
return new MatrixEvent({
|
||||
// TODO: Add rest of the event keys.
|
||||
// TODO: Add a key to indicate that the event was encrypted.
|
||||
type: payload.type,
|
||||
content: payload.content,
|
||||
user_id: event.getSender()
|
||||
});
|
||||
} else {
|
||||
return _badEncryptedMessge(event, "Bad Encrypted Message");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _badEncryptedMessge(event, reason) {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
// TODO: Add rest of the event keys.
|
||||
content: {
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: reason,
|
||||
content: event.getContent()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _sendEvent(client, room, event, callback) {
|
||||
var defer = q.defer();
|
||||
var promise;
|
||||
@@ -900,7 +1162,7 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) {
|
||||
var defer = q.defer();
|
||||
var self = this;
|
||||
this._http.authedRequest(callback, "GET", path, params).done(function(res) {
|
||||
var matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper);
|
||||
var matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self));
|
||||
room.addEventsToTimeline(matrixEvents, true);
|
||||
room.oldState.paginationToken = res.end;
|
||||
if (res.chunk.length < limit) {
|
||||
@@ -1055,7 +1317,8 @@ function doInitialSync(client, historyLen) {
|
||||
var i, j;
|
||||
// intercept the results and put them into our store
|
||||
if (!(client.store instanceof StubStore)) {
|
||||
utils.forEach(utils.map(data.presence, _PojoToMatrixEventMapper),
|
||||
utils.forEach(
|
||||
utils.map(data.presence, _PojoToMatrixEventMapper(client)),
|
||||
function(e) {
|
||||
var user = createNewUser(client, e.getContent().user_id);
|
||||
user.setPresenceEvent(e);
|
||||
@@ -1081,7 +1344,7 @@ function doInitialSync(client, historyLen) {
|
||||
}
|
||||
|
||||
_processRoomEvents(
|
||||
room, data.rooms[i].state, data.rooms[i].messages
|
||||
client, room, data.rooms[i].state, data.rooms[i].messages
|
||||
);
|
||||
|
||||
// cache the name/summary/etc prior to storage since we don't
|
||||
@@ -1266,7 +1529,7 @@ function _syncRoom(client, room) {
|
||||
client._syncingRooms[room.roomId] = defer.promise;
|
||||
client.roomInitialSync(room.roomId, 8).done(function(res) {
|
||||
room.timeline = []; // blow away any previous messages.
|
||||
_processRoomEvents(room, res.state, res.messages);
|
||||
_processRoomEvents(client, room, res.state, res.messages);
|
||||
room.recalculate(client.credentials.userId);
|
||||
client.store.storeRoom(room);
|
||||
client.emit("Room", room);
|
||||
@@ -1279,15 +1542,15 @@ function _syncRoom(client, room) {
|
||||
return defer.promise;
|
||||
}
|
||||
|
||||
function _processRoomEvents(room, stateEventList, messageChunk) {
|
||||
function _processRoomEvents(client, room, stateEventList, messageChunk) {
|
||||
// "old" and "current" state are the same initially; they
|
||||
// start diverging if the user paginates.
|
||||
// We must deep copy otherwise membership changes in old state
|
||||
// will leak through to current state!
|
||||
var oldStateEvents = utils.map(
|
||||
utils.deepCopy(stateEventList), _PojoToMatrixEventMapper
|
||||
utils.deepCopy(stateEventList), _PojoToMatrixEventMapper(client)
|
||||
);
|
||||
var stateEvents = utils.map(stateEventList, _PojoToMatrixEventMapper);
|
||||
var stateEvents = utils.map(stateEventList, _PojoToMatrixEventMapper(client));
|
||||
room.oldState.setStateEvents(oldStateEvents);
|
||||
room.currentState.setStateEvents(stateEvents);
|
||||
|
||||
@@ -1299,7 +1562,7 @@ function _processRoomEvents(room, stateEventList, messageChunk) {
|
||||
room.addEventsToTimeline(
|
||||
utils.map(
|
||||
messageChunk ? messageChunk.chunk : [],
|
||||
_PojoToMatrixEventMapper
|
||||
_PojoToMatrixEventMapper(client)
|
||||
).reverse(), true
|
||||
);
|
||||
if (messageChunk) {
|
||||
@@ -1377,8 +1640,16 @@ function _resolve(callback, defer, res) {
|
||||
defer.resolve(res);
|
||||
}
|
||||
|
||||
function _PojoToMatrixEventMapper(plainOldJsObject) {
|
||||
return new MatrixEvent(plainOldJsObject);
|
||||
function _PojoToMatrixEventMapper(client) {
|
||||
function mapper (plainOldJsObject) {
|
||||
var event = new MatrixEvent(plainOldJsObject);
|
||||
if (event.getType() === "m.room.encrypted") {
|
||||
return _decryptMessage(client, event);
|
||||
} else {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
return mapper;
|
||||
}
|
||||
|
||||
/** */
|
||||
|
Reference in New Issue
Block a user