Merge branch 'room-feature-key-sharing' into 'master'

[Room] Implement key sharing

See merge request famedly/famedlysdk!217
This commit is contained in:
Christian Pauly 2020-02-21 15:05:19 +00:00
commit 8a3547a1ee
10 changed files with 340 additions and 50 deletions

View file

@ -35,6 +35,7 @@ export 'package:famedlysdk/src/utils/open_id_credentials.dart';
export 'package:famedlysdk/src/utils/profile.dart';
export 'package:famedlysdk/src/utils/push_rules.dart';
export 'package:famedlysdk/src/utils/receipt.dart';
export 'package:famedlysdk/src/utils/room_key_request.dart';
export 'package:famedlysdk/src/utils/states_map.dart';
export 'package:famedlysdk/src/utils/to_device_event.dart';
export 'package:famedlysdk/src/utils/turn_server_credentials.dart';

View file

@ -33,6 +33,7 @@ import 'package:famedlysdk/src/sync/user_update.dart';
import 'package:famedlysdk/src/utils/device_keys_list.dart';
import 'package:famedlysdk/src/utils/matrix_file.dart';
import 'package:famedlysdk/src/utils/open_id_credentials.dart';
import 'package:famedlysdk/src/utils/room_key_request.dart';
import 'package:famedlysdk/src/utils/session_key.dart';
import 'package:famedlysdk/src/utils/to_device_event.dart';
import 'package:famedlysdk/src/utils/turn_server_credentials.dart';
@ -620,6 +621,10 @@ class Client {
/// Will be called on call answers.
final StreamController<Event> onCallAnswer = StreamController.broadcast();
/// Will be called when another device is requesting session keys for a room.
final StreamController<RoomKeyRequest> onRoomKeyRequest =
StreamController.broadcast();
/// Matrix synchronisation is done with https long polling. This needs a
/// timeout which is usually 30 seconds.
int syncTimeoutSec = 30;
@ -1320,17 +1325,67 @@ class Client {
try {
switch (toDeviceEvent.type) {
case "m.room_key":
case "m.forwarded_room_key":
Room room = getRoomById(toDeviceEvent.content["room_id"]);
if (room != null && toDeviceEvent.content["session_id"] is String) {
if (room != null) {
final String sessionId = toDeviceEvent.content["session_id"];
if (room != null) {
room.setSessionKey(sessionId, toDeviceEvent.content);
if (toDeviceEvent.type == "m.room_key" &&
userDeviceKeys.containsKey(toDeviceEvent.sender) &&
userDeviceKeys[toDeviceEvent.sender].deviceKeys.containsKey(
toDeviceEvent.content["requesting_device_id"])) {
toDeviceEvent.content["sender_claimed_ed25519_key"] =
userDeviceKeys[toDeviceEvent.sender]
.deviceKeys[
toDeviceEvent.content["requesting_device_id"]]
.ed25519Key;
}
room.setSessionKey(
sessionId,
toDeviceEvent.content,
forwarded: toDeviceEvent.type == "m.forwarded_room_key",
);
if (toDeviceEvent.type == "m.forwarded_room_key") {
sendToDevice(
[],
"m.room_key_request",
{
"action": "request_cancellation",
"request_id": base64.encode(
utf8.encode(toDeviceEvent.content["room_id"])),
"requesting_device_id": room.client.deviceID,
});
}
}
}
break;
case "m.room_key_request":
if (!toDeviceEvent.content.containsKey("body")) break;
Room room = getRoomById(toDeviceEvent.content["body"]["room_id"]);
DeviceKeys deviceKeys;
final String sessionId = toDeviceEvent.content["body"]["session_id"];
if (userDeviceKeys.containsKey(toDeviceEvent.sender) &&
userDeviceKeys[toDeviceEvent.sender]
.deviceKeys
.containsKey(toDeviceEvent.content["requesting_device_id"])) {
deviceKeys = userDeviceKeys[toDeviceEvent.sender]
.deviceKeys[toDeviceEvent.content["requesting_device_id"]];
if (room.sessionKeys.containsKey(sessionId)) {
final RoomKeyRequest roomKeyRequest =
RoomKeyRequest.fromToDeviceEvent(toDeviceEvent, this);
if (deviceKeys.userId == userID &&
deviceKeys.verified &&
!deviceKeys.blocked) {
roomKeyRequest.forwardKey();
} else {
onRoomKeyRequest.add(roomKeyRequest);
}
}
}
break;
}
} catch (e) {
print(e);
print("[Matrix] Error while processing to-device-event: " + e.toString());
}
}
@ -1650,65 +1705,80 @@ class Client {
storeAPI?.setItem("/clients/$userID/olm-sessions", json.encode(pickleMap));
}
/// Sends an encrypted [message] of this [type] to these [deviceKeys].
Future<void> sendToDevice(List<DeviceKeys> deviceKeys, String type,
Map<String, dynamic> message) async {
if (!encryptionEnabled) return;
/// Sends an encrypted [message] of this [type] to these [deviceKeys]. To send
/// the request to all devices of the current user, pass an empty list to [deviceKeys].
Future<void> sendToDevice(
List<DeviceKeys> deviceKeys, String type, Map<String, dynamic> message,
{bool encrypted = true}) async {
if (encrypted && !encryptionEnabled) return;
// Don't send this message to blocked devices.
if (deviceKeys?.isEmpty ?? true) return;
deviceKeys.removeWhere((DeviceKeys deviceKeys) =>
deviceKeys.blocked || deviceKeys.deviceId == deviceID);
if (deviceKeys?.isEmpty ?? true) return;
// Create new sessions with devices if there is no existing session yet.
List<DeviceKeys> deviceKeysWithoutSession =
List<DeviceKeys>.from(deviceKeys);
deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) =>
olmSessions.containsKey(deviceKeys.curve25519Key));
if (deviceKeysWithoutSession.isNotEmpty) {
await startOutgoingOlmSessions(deviceKeysWithoutSession);
if (deviceKeys.isNotEmpty) {
deviceKeys.removeWhere((DeviceKeys deviceKeys) =>
deviceKeys.blocked || deviceKeys.deviceId == deviceID);
if (deviceKeys.isEmpty) return;
}
Map<String, dynamic> encryptedMessage = {
"algorithm": "m.olm.v1.curve25519-aes-sha2",
"sender_key": identityKey,
"ciphertext": Map<String, dynamic>(),
};
for (DeviceKeys device in deviceKeys) {
List<olm.Session> existingSessions = olmSessions[device.curve25519Key];
if (existingSessions == null || existingSessions.isEmpty) continue;
existingSessions.sort((a, b) => a.session_id().compareTo(b.session_id()));
final Map<String, dynamic> payload = {
"type": type,
"content": message,
"sender": this.userID,
"keys": {"ed25519": fingerprintKey},
"recipient": device.userId,
"recipient_keys": {"ed25519": device.ed25519Key},
};
final olm.EncryptResult encryptResult =
existingSessions.first.encrypt(json.encode(payload));
storeOlmSession(device.curve25519Key, existingSessions.first);
encryptedMessage["ciphertext"][device.curve25519Key] = {
"type": encryptResult.type,
"body": encryptResult.body,
Map<String, dynamic> sendToDeviceMessage = message;
if (encrypted) {
// Create new sessions with devices if there is no existing session yet.
List<DeviceKeys> deviceKeysWithoutSession =
List<DeviceKeys>.from(deviceKeys);
deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) =>
olmSessions.containsKey(deviceKeys.curve25519Key));
if (deviceKeysWithoutSession.isNotEmpty) {
await startOutgoingOlmSessions(deviceKeysWithoutSession);
}
sendToDeviceMessage = {
"algorithm": "m.olm.v1.curve25519-aes-sha2",
"sender_key": identityKey,
"ciphertext": Map<String, dynamic>(),
};
for (DeviceKeys device in deviceKeys) {
List<olm.Session> existingSessions = olmSessions[device.curve25519Key];
if (existingSessions == null || existingSessions.isEmpty) continue;
existingSessions
.sort((a, b) => a.session_id().compareTo(b.session_id()));
final Map<String, dynamic> payload = {
"type": type,
"content": message,
"sender": this.userID,
"keys": {"ed25519": fingerprintKey},
"recipient": device.userId,
"recipient_keys": {"ed25519": device.ed25519Key},
};
final olm.EncryptResult encryptResult =
existingSessions.first.encrypt(json.encode(payload));
storeOlmSession(device.curve25519Key, existingSessions.first);
sendToDeviceMessage["ciphertext"][device.curve25519Key] = {
"type": encryptResult.type,
"body": encryptResult.body,
};
}
type = "m.room.encrypted";
}
// Send with send-to-device messaging
Map<String, dynamic> data = {
"messages": Map<String, dynamic>(),
};
for (DeviceKeys device in deviceKeys) {
if (!data["messages"].containsKey(device.userId)) {
data["messages"][device.userId] = Map<String, dynamic>();
if (deviceKeys.isEmpty) {
data["messages"][this.userID] = Map<String, dynamic>();
data["messages"][this.userID]["*"] = sendToDeviceMessage;
} else {
for (int i = 0; i < deviceKeys.length; i++) {
DeviceKeys device = deviceKeys[i];
if (!data["messages"].containsKey(device.userId)) {
data["messages"][device.userId] = Map<String, dynamic>();
}
data["messages"][device.userId][device.deviceId] = sendToDeviceMessage;
}
data["messages"][device.userId][device.deviceId] = encryptedMessage;
}
final String messageID = "msg${DateTime.now().millisecondsSinceEpoch}";
await jsonRequest(
type: HTTPType.PUT,
action: "/client/r0/sendToDevice/m.room.encrypted/$messageID",
action: "/client/r0/sendToDevice/$type/$messageID",
data: data,
);
}

View file

@ -388,6 +388,42 @@ class Event {
/// Trys to decrypt this event. Returns a m.bad.encrypted event
/// if it fails and does nothing if the event was not encrypted.
Event get decrypted => room.decryptGroupMessage(this);
/// If this event is encrypted and the decryption was not successful because
/// the session is unknown, this requests the session key from other devices
/// in the room. If the event is not encrypted or the decryption failed because
/// of a different error, this throws an exception.
Future<void> requestKey() async {
if (this.type != EventTypes.Encrypted ||
this.messageType != MessageTypes.BadEncrypted ||
this.content["body"] != DecryptError.UNKNOWN_SESSION) {
throw ("Session key not unknown");
}
await room.client.sendToDevice(
[],
"m.room_key_request",
{
"action": "request_cancellation",
"request_id": base64.encode(utf8.encode(content["session_id"])),
"requesting_device_id": room.client.deviceID,
});
await room.client.sendToDevice(
[],
"m.room_key_request",
{
"action": "request",
"body": {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": roomId,
"sender_key": content["sender_key"],
"session_id": content["session_id"],
},
"request_id": base64.encode(utf8.encode(content["session_id"])),
"requesting_device_id": room.client.deviceID,
},
encrypted: false);
return;
}
}
enum MessageTypes {

View file

@ -152,13 +152,18 @@ class Room {
Map<String, SessionKey> _sessionKeys = {};
/// Add a new session key to the [sessionKeys].
void setSessionKey(String sessionId, Map<String, dynamic> content) {
void setSessionKey(String sessionId, Map<String, dynamic> content,
{bool forwarded = false}) {
if (sessionKeys.containsKey(sessionId)) return;
olm.InboundGroupSession inboundGroupSession;
if (content["algorithm"] == "m.megolm.v1.aes-sha2") {
try {
inboundGroupSession = olm.InboundGroupSession();
inboundGroupSession.create(content["session_key"]);
if (forwarded) {
inboundGroupSession.import_session(content["session_key"]);
} else {
inboundGroupSession.create(content["session_key"]);
}
} catch (e) {
inboundGroupSession = null;
print("[LibOlm] Could not create new InboundGroupSession: " +
@ -176,6 +181,7 @@ class Room {
"/clients/${client.deviceID}/rooms/${this.id}/session_keys",
json.encode(sessionKeys));
}
onSessionKeyReceived.add(sessionId);
}
/// Returns the [Event] for the given [typeKey] and optional [stateKey].
@ -219,6 +225,11 @@ class Room {
/// room id.
final StreamController<String> onUpdate = StreamController.broadcast();
/// If there is a new session key received, this will be triggered with
/// the session ID.
final StreamController<String> onSessionKeyReceived =
StreamController.broadcast();
/// The name of the room if set by a participant.
String get name => states["m.room.name"] != null
? states["m.room.name"].content["name"]

View file

@ -42,6 +42,7 @@ class Timeline {
final onTimelineInsertCallback onInsert;
StreamSubscription<EventUpdate> sub;
StreamSubscription<String> sessionIdReceivedSub;
bool _requestingHistoryLock = false;
Map<String, Event> _eventCache = {};
@ -77,6 +78,30 @@ class Timeline {
Timeline({this.room, this.events, this.onUpdate, this.onInsert}) {
sub ??= room.client.onEvent.stream.listen(_handleEventUpdate);
sessionIdReceivedSub ??=
room.onSessionKeyReceived.stream.listen(_sessionKeyReceived);
}
/// Don't forget to call this before you dismiss this object!
void cancelSubscriptions() {
sub?.cancel();
sessionIdReceivedSub?.cancel();
}
void _sessionKeyReceived(String sessionId) {
bool decryptAtLeastOneEvent = false;
for (int i = 0; i < events.length; i++) {
if (events[i].type == EventTypes.Encrypted &&
events[i].messageType == MessageTypes.BadEncrypted &&
events[i].content["body"] == DecryptError.UNKNOWN_SESSION &&
events[i].content["session_id"] == sessionId) {
events[i] = events[i].decrypted;
if (events[i].type != EventTypes.Encrypted) {
decryptAtLeastOneEvent = true;
}
}
}
if (decryptAtLeastOneEvent) onUpdate();
}
int _findEvent({String event_id, String unsigned_txid}) {

View file

@ -0,0 +1,36 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/src/utils/session_key.dart';
class RoomKeyRequest extends ToDeviceEvent {
Client client;
RoomKeyRequest.fromToDeviceEvent(ToDeviceEvent toDeviceEvent, Client client) {
this.client = client;
this.sender = toDeviceEvent.sender;
this.content = toDeviceEvent.content;
this.type = toDeviceEvent.type;
}
Room get room => client.getRoomById(this.content["body"]["room_id"]);
DeviceKeys get requestingDevice =>
client.userDeviceKeys[sender].deviceKeys[content["requesting_device_id"]];
Future<void> forwardKey() async {
Room room = this.room;
final SessionKey session =
room.sessionKeys[this.content["body"]["session_id"]];
List<dynamic> forwardedKeys = [client.identityKey];
for (final key in session.forwardingCurve25519KeyChain) {
forwardedKeys.add(key);
}
await requestingDevice.setVerified(true, client);
Map<String, dynamic> message = session.content;
message["forwarding_curve25519_key_chain"] = forwardedKeys;
message["session_key"] = session.inboundGroupSession.export_session(0);
await client.sendToDevice(
[requestingDevice],
"m.forwarded_room_key",
message,
);
}
}

View file

@ -7,6 +7,10 @@ class SessionKey {
Map<String, int> indexes;
InboundGroupSession inboundGroupSession;
final String key;
List<dynamic> get forwardingCurve25519KeyChain =>
content["forwarding_curve25519_key_chain"] ?? [];
String get senderClaimedEd25519Key =>
content["sender_claimed_ed25519_key"] ?? "";
SessionKey({this.content, this.inboundGroupSession, this.key, this.indexes});

View file

@ -209,5 +209,42 @@ void main() {
expect(resp1, null);
expect(resp2, "42");
});
test("requestKey", () async {
Client matrix = Client("testclient", debug: true);
matrix.httpClient = FakeMatrixApi();
await matrix.checkServer("https://fakeServer.notExisting");
await matrix.login("test", "1234");
Event event = Event.fromJson(
jsonObj, Room(id: "!1234:example.com", client: matrix));
String exception;
try {
await event.requestKey();
} catch (e) {
exception = e;
}
expect(exception, "Session key not unknown");
event = Event.fromJson({
"event_id": id,
"sender": senderID,
"origin_server_ts": timestamp,
"type": "m.room.encrypted",
"room_id": "1234",
"status": 2,
"content": json.encode({
"msgtype": "m.bad.encrypted",
"body": DecryptError.UNKNOWN_SESSION,
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "AwgAEnACgAkLmt6qF84IK++J7UDH2Za1YVchHyprqTqsg...",
"device_id": "RJYKSTBOIE",
"sender_key": "IlRMeOPX2e0MurIyfWEucYBRVOEEUMrOHqn/8mLqMjA",
"session_id": "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ"
}),
}, Room(id: "!1234:example.com", client: matrix));
await event.requestKey();
});
});
}

View file

@ -58,7 +58,7 @@ class FakeMatrixApi extends MockClient {
return Response(json.encode(res), 405);
}
} else if (method == "PUT" &&
action.contains("/client/r0/sendToDevice/m.room.encrypted/")) {
action.contains("/client/r0/sendToDevice/")) {
return Response(json.encode({}), 200);
} else if (method == "GET" &&
action.contains("/client/r0/rooms/") &&

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2019 Zender & Kurtz GbR.
*
* Authors:
* Christian Pauly <krille@famedly.com>
* Marcel Radzio <mtrnord@famedly.com>
*
* This file is part of famedlysdk.
*
* famedlysdk is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* famedlysdk is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with famedlysdk. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:famedlysdk/famedlysdk.dart';
import 'package:test/test.dart';
import 'fake_matrix_api.dart';
import 'fake_store.dart';
void main() {
/// All Tests related to device keys
group("Room Key Request", () {
test("fromJson", () async {
Map<String, dynamic> rawJson = {
"content": {
"action": "request",
"body": {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!726s6s6q:example.com",
"sender_key": "RF3s+E7RkTQTGF2d8Deol0FkQvgII2aJDf3/Jp5mxVU",
"session_id": "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ"
},
"request_id": "1495474790150.19",
"requesting_device_id": "JLAFKJWSCS"
},
"type": "m.room_key_request",
"sender": "@alice:example.com"
};
ToDeviceEvent toDeviceEvent = ToDeviceEvent.fromJson(rawJson);
expect(toDeviceEvent.content, rawJson["content"]);
expect(toDeviceEvent.sender, rawJson["sender"]);
expect(toDeviceEvent.type, rawJson["type"]);
Client matrix = Client("testclient", debug: true);
matrix.httpClient = FakeMatrixApi();
matrix.storeAPI = FakeStore(matrix, {});
await matrix.checkServer("https://fakeServer.notExisting");
await matrix.login("test", "1234");
Room room = matrix.getRoomById("!726s6s6q:example.com");
if (matrix.encryptionEnabled) {
await room.createOutboundGroupSession();
rawJson["content"]["body"]["session_id"] = room.sessionKeys.keys.first;
RoomKeyRequest roomKeyRequest = RoomKeyRequest.fromToDeviceEvent(
ToDeviceEvent.fromJson(rawJson), matrix);
await roomKeyRequest.forwardKey();
}
});
});
}