[SDK] Add dart-olm library and update CI

This commit is contained in:
Christian Pauly 2020-02-15 07:48:41 +00:00
parent a43f659a48
commit f5b493f9bd
19 changed files with 1446 additions and 37 deletions

1
.gitignore vendored
View file

@ -8,6 +8,7 @@
.buildlog/
.history
.svn/
native/
# IntelliJ related
*.iml

View file

@ -7,6 +7,26 @@ variables:
JEKYLL_ENV: production
coverage:
image: debian:testing
stage: coverage
coverage: '/^\s+lines.+: (\d+.\d*%)/'
dependencies: []
script:
- apt update
- apt install -y curl gnupg2 git
- curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
- curl https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > /etc/apt/sources.list.d/dart_stable.list
- apt update
- apt install -y dart chromium lcov libolm3
- ln -s /usr/lib/dart/bin/pub /usr/bin/
- useradd -m test
- chown -R 'test:' '.'
- chmod +x ./prepare.sh
- chmod +x ./test.sh
- su -c ./prepare.sh test
- su -c ./test.sh test
coverage_without_olm:
image: cirrusci/flutter
stage: coverage
coverage: '/^\s+lines.+: (\d+.\d*%)/'

View file

@ -36,6 +36,7 @@ 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/states_map.dart';
export 'package:famedlysdk/src/utils/to_device_event.dart';
export 'package:famedlysdk/src/utils/turn_server_credentials.dart';
export 'package:famedlysdk/src/account_data.dart';
export 'package:famedlysdk/src/client.dart';

View file

@ -24,6 +24,7 @@
import 'dart:async';
import 'dart:core';
import 'package:canonical_json/canonical_json.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/src/account_data.dart';
import 'package:famedlysdk/src/presence.dart';
@ -32,7 +33,9 @@ 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/to_device_event.dart';
import 'package:famedlysdk/src/utils/turn_server_credentials.dart';
import 'package:olm/olm.dart' as olm;
import 'package:pedantic/pedantic.dart';
import 'room.dart';
import 'event.dart';
@ -117,6 +120,16 @@ class Client {
List<Room> get rooms => _rooms;
List<Room> _rooms = [];
olm.Account _olmAccount;
/// Returns the base64 encoded keys to store them in a store.
/// This String should **never** leave the device!
String get pickledOlmAccount =>
encryptionEnabled ? _olmAccount.pickle(userID) : null;
/// Whether this client supports end-to-end encryption using olm.
bool get encryptionEnabled => _olmAccount != null;
/// Warning! This endpoint is for testing only!
set rooms(List<Room> newList) {
print("Warning! This endpoint is for testing only!");
@ -284,6 +297,9 @@ class Client {
newDeviceID: response["device_id"],
newMatrixVersions: matrixVersions,
newLazyLoadMembers: lazyLoadMembers);
if (await this._uploadKeys(uploadDeviceKeys: true) == false) {
await this.logout();
}
}
return response;
}
@ -319,13 +335,18 @@ class Client {
loginResp.containsKey("access_token") &&
loginResp.containsKey("device_id")) {
await this.connect(
newToken: loginResp["access_token"],
newUserID: loginResp["user_id"],
newHomeserver: homeserver,
newDeviceName: initialDeviceDisplayName ?? "",
newDeviceID: loginResp["device_id"],
newMatrixVersions: matrixVersions,
newLazyLoadMembers: lazyLoadMembers);
newToken: loginResp["access_token"],
newUserID: loginResp["user_id"],
newHomeserver: homeserver,
newDeviceName: initialDeviceDisplayName ?? "",
newDeviceID: loginResp["device_id"],
newMatrixVersions: matrixVersions,
newLazyLoadMembers: lazyLoadMembers,
);
if (await this._uploadKeys(uploadDeviceKeys: true) == false) {
await this.logout();
return false;
}
return true;
}
return false;
@ -564,6 +585,11 @@ class Client {
/// Outside of rooms there are account updates like account_data or presences.
final StreamController<UserUpdate> onUserEvent = StreamController.broadcast();
/// The onToDeviceEvent is called when there comes a new to device event. It is
/// already decrypted if necessary.
final StreamController<ToDeviceEvent> onToDeviceEvent =
StreamController.broadcast();
/// Called when the login state e.g. user gets logged out.
final StreamController<LoginState> onLoginStateChanged =
StreamController.broadcast();
@ -646,6 +672,7 @@ class Client {
List<String> newMatrixVersions,
bool newLazyLoadMembers,
String newPrevBatch,
String newOlmAccount,
}) async {
this._accessToken = newToken;
this._homeserver = newHomeserver;
@ -656,9 +683,46 @@ class Client {
this._lazyLoadMembers = newLazyLoadMembers;
this.prevBatch = newPrevBatch;
// Try to create a new olm account or restore a previous one.
if (newOlmAccount == null) {
try {
await olm.init();
this._olmAccount = olm.Account();
this._olmAccount.create();
} catch (_) {
this._olmAccount = null;
}
} else {
try {
await olm.init();
this._olmAccount = olm.Account();
this._olmAccount.unpickle(userID, newOlmAccount);
} catch (_) {
this._olmAccount = null;
}
}
if (this.storeAPI != null) {
await this.storeAPI.storeClient();
_userDeviceKeys = await this.storeAPI.getUserDeviceKeys();
final String olmSessionPickleString =
await storeAPI.getItem("/clients/$userID/olm-sessions");
if (olmSessionPickleString != null) {
final Map<String, List<String>> pickleMap =
json.decode(olmSessionPickleString);
for (var entry in pickleMap.entries) {
for (String pickle in entry.value) {
_olmSessions[entry.key] = [];
try {
olm.Session session = olm.Session();
session.unpickle(userID, pickle);
_olmSessions[entry.key].add(session);
} catch (e) {
print("[LibOlm] Could not unpickle olm session: " + e.toString());
}
}
}
}
if (this.store != null) {
this._rooms = await this.store.getRoomList(onlyLeft: false);
this._sortRooms();
@ -852,6 +916,7 @@ class Client {
}
}
/// Use this method only for testing utilities!
void handleSync(dynamic sync) {
if (sync["rooms"] is Map<String, dynamic>) {
if (sync["rooms"]["join"] is Map<String, dynamic>) {
@ -874,22 +939,57 @@ class Client {
}
if (sync["to_device"] is Map<String, dynamic> &&
sync["to_device"]["events"] is List<dynamic>) {
_handleGlobalEvents(sync["to_device"]["events"], "to_device");
_handleToDeviceEvents(sync["to_device"]["events"]);
}
if (sync["device_lists"] is Map<String, dynamic>) {
_handleDeviceListsEvents(sync["device_lists"]);
}
if (sync["device_one_time_keys_count"] is Map<String, dynamic>) {
_handleDeviceOneTimeKeysCount(sync["device_one_time_keys_count"]);
}
onSync.add(sync);
}
void _handleDeviceOneTimeKeysCount(
Map<String, dynamic> deviceOneTimeKeysCount) {
if (!encryptionEnabled) return;
// Check if there are at least half of max_number_of_one_time_keys left on the server
// and generate and upload more if not.
if (deviceOneTimeKeysCount["signed_curve25519"] is int) {
final int oneTimeKeysCount = deviceOneTimeKeysCount["signed_curve25519"];
if (oneTimeKeysCount < (_olmAccount.max_number_of_one_time_keys() / 2)) {
// Generate and upload more one time keys:
_uploadKeys();
}
}
}
/// Clears the outboundGroupSession from all rooms where this user is
/// participating. Should be called when the user's devices list has changed.
void _clearOutboundGroupSessionsByUserId(String userId) {
for (Room room in rooms) {
if (!room.encrypted) continue;
room.requestParticipants().then((List<User> users) {
if (users.indexWhere((u) =>
u.id == userId &&
[Membership.join, Membership.invite].contains(u.membership)) !=
-1) {
room.clearOutboundGroupSession();
}
});
}
}
void _handleDeviceListsEvents(Map<String, dynamic> deviceLists) {
if (deviceLists["changed"] is List) {
for (final userId in deviceLists["changed"]) {
_clearOutboundGroupSessionsByUserId(userId);
if (_userDeviceKeys.containsKey(userId)) {
_userDeviceKeys[userId].outdated = true;
}
}
for (final userId in deviceLists["left"]) {
_clearOutboundGroupSessionsByUserId(userId);
if (_userDeviceKeys.containsKey(userId)) {
_userDeviceKeys.remove(userId);
}
@ -897,6 +997,30 @@ class Client {
}
}
void _handleToDeviceEvents(List<dynamic> events) {
for (int i = 0; i < events.length; i++) {
bool isValid = events[i] is Map &&
events[i]["type"] is String &&
events[i]["sender"] is String &&
events[i]["content"] is Map;
if (!isValid) {
print("[Sync] Invalid To Device Event! ${events[i]}");
continue;
}
ToDeviceEvent toDeviceEvent = ToDeviceEvent.fromJson(events[i]);
if (toDeviceEvent.type == "m.room.encrypted") {
try {
toDeviceEvent = decryptToDeviceEvent(toDeviceEvent);
} catch (e) {
print("[LibOlm] Could not decrypt to device event: " + e.toString());
toDeviceEvent = ToDeviceEvent.fromJson(events[i]);
}
}
_updateRoomsByToDeviceEvent(toDeviceEvent);
onToDeviceEvent.add(toDeviceEvent);
}
}
void _handleRooms(Map<String, dynamic> rooms, Membership membership) {
rooms.forEach((String id, dynamic room) async {
// calculate the notification counts, the limitedTimeline and prevbatch
@ -1052,8 +1176,24 @@ class Client {
type: type,
content: event,
);
_updateRoomsByEventUpdate(update);
this.store?.storeEventUpdate(update);
if (event["type"] == "m.room.encrypted") {
Room room = getRoomById(roomID);
try {
Event decrpytedEvent =
room.decryptGroupMessage(Event.fromJson(event, room));
event = decrpytedEvent.toJson();
update = EventUpdate(
eventType: event["type"],
roomID: roomID,
type: type,
content: event,
);
} catch (e) {
print("[LibOlm] Could not decrypt megolm event: " + e.toString());
}
}
_updateRoomsByEventUpdate(update);
onEvent.add(update);
if (event["type"] == "m.call.invite") {
@ -1094,6 +1234,7 @@ class Client {
roomAccountData: {},
client: this,
);
newRoom.restoreGroupSessionKeys();
rooms.insert(position, newRoom);
}
// If the membership is "leave" then remove the item and stop here
@ -1172,6 +1313,22 @@ class Client {
if (eventUpdate.type == "timeline") _sortRooms();
}
void _updateRoomsByToDeviceEvent(ToDeviceEvent toDeviceEvent) {
try {
switch (toDeviceEvent.type) {
case "m.room_key":
Room room = getRoomById(toDeviceEvent.content["room_id"]);
if (room != null && toDeviceEvent.content["session_id"] is String) {
final String sessionId = toDeviceEvent.content["session_id"];
room.setSessionKey(sessionId, toDeviceEvent.content);
}
break;
}
} catch (e) {
print(e);
}
}
bool _sortLock = false;
/// The compare function how the rooms should be sorted internally. By default
@ -1253,4 +1410,302 @@ class Client {
}
await this.storeAPI?.storeUserDeviceKeys(userDeviceKeys);
}
String get fingerprintKey => encryptionEnabled
? json.decode(_olmAccount.identity_keys())["ed25519"]
: null;
String get identityKey => encryptionEnabled
? json.decode(_olmAccount.identity_keys())["curve25519"]
: null;
/// Adds a signature to this json from this olm account.
Map<String, dynamic> signJson(Map<String, dynamic> payload) {
if (!encryptionEnabled) throw ("Encryption is disabled");
final Map<String, dynamic> unsigned = payload["unsigned"];
final Map<String, dynamic> signatures = payload["signatures"];
payload.remove("unsigned");
payload.remove("signatures");
final List<int> canonical = canonicalJson.encode(payload);
final String signature = _olmAccount.sign(String.fromCharCodes(canonical));
if (signatures != null) {
payload["signatures"] = signatures;
} else {
payload["signatures"] = Map<String, dynamic>();
}
payload["signatures"][userID] = Map<String, dynamic>();
payload["signatures"][userID]["ed25519:$deviceID"] = signature;
if (unsigned != null) {
payload["unsigned"] = unsigned;
}
return payload;
}
/// Checks the signature of a signed json object.
bool checkJsonSignature(String key, Map<String, dynamic> signedJson,
String userId, String deviceId) {
if (!encryptionEnabled) throw ("Encryption is disabled");
final Map<String, dynamic> signatures = signedJson["signatures"];
if (signatures == null || !signatures.containsKey(userId)) return false;
signedJson.remove("unsigned");
signedJson.remove("signatures");
if (!signatures[userId].containsKey("ed25519:$deviceId")) return false;
final String signature = signatures[userId]["ed25519:$deviceId"];
final List<int> canonical = canonicalJson.encode(signedJson);
final String message = String.fromCharCodes(canonical);
bool isValid = true;
try {
olm.Utility()
..ed25519_verify(key, message, signature)
..free();
} catch (e) {
isValid = false;
print("[LibOlm] Signature check failed: " + e.toString());
}
return isValid;
}
DateTime lastTimeKeysUploaded;
/// Generates new one time keys, signs everything and upload it to the server.
Future<bool> _uploadKeys({bool uploadDeviceKeys = false}) async {
if (!encryptionEnabled) return true;
final int oneTimeKeysCount = _olmAccount.max_number_of_one_time_keys();
_olmAccount.generate_one_time_keys(oneTimeKeysCount);
final Map<String, dynamic> oneTimeKeys =
json.decode(_olmAccount.one_time_keys());
Map<String, dynamic> signedOneTimeKeys = Map<String, dynamic>();
for (String key in oneTimeKeys["curve25519"].keys) {
signedOneTimeKeys["signed_curve25519:$key"] = Map<String, dynamic>();
signedOneTimeKeys["signed_curve25519:$key"]["key"] =
oneTimeKeys["curve25519"][key];
signedOneTimeKeys["signed_curve25519:$key"] =
signJson(signedOneTimeKeys["signed_curve25519:$key"]);
}
Map<String, dynamic> keysContent = {
if (uploadDeviceKeys)
"device_keys": {
"user_id": userID,
"device_id": deviceID,
"algorithms": [
"m.olm.v1.curve25519-aes-sha2",
"m.megolm.v1.aes-sha2"
],
"keys": Map<String, dynamic>(),
},
"one_time_keys": signedOneTimeKeys,
};
if (uploadDeviceKeys) {
final Map<String, dynamic> keys =
json.decode(_olmAccount.identity_keys());
for (String algorithm in keys.keys) {
keysContent["device_keys"]["keys"]["$algorithm:$deviceID"] =
keys[algorithm];
}
keysContent["device_keys"] =
signJson(keysContent["device_keys"] as Map<String, dynamic>);
}
final Map<String, dynamic> response = await jsonRequest(
type: HTTPType.POST,
action: "/client/r0/keys/upload",
data: keysContent,
);
if (response["one_time_key_counts"]["signed_curve25519"] !=
oneTimeKeysCount) {
return false;
}
_olmAccount.mark_keys_as_published();
await storeAPI?.storeClient();
lastTimeKeysUploaded = DateTime.now();
return true;
}
/// Try to decrypt a ToDeviceEvent encrypted with olm.
ToDeviceEvent decryptToDeviceEvent(ToDeviceEvent toDeviceEvent) {
if (toDeviceEvent.content["algorithm"] != "m.olm.v1.curve25519-aes-sha2") {
throw ("Unknown algorithm: ${toDeviceEvent.content["algorithm"]}");
}
if (!toDeviceEvent.content["ciphertext"].containsKey(identityKey)) {
throw ("The message isn't sent for this device");
}
String plaintext;
final String senderKey = toDeviceEvent.content["sender_key"];
final String body =
toDeviceEvent.content["ciphertext"][identityKey]["body"];
final int type = toDeviceEvent.content["ciphertext"][identityKey]["type"];
if (type != 0 && type != 1) {
throw ("Unknown message type");
}
List<olm.Session> existingSessions = olmSessions[senderKey];
if (existingSessions != null) {
for (olm.Session session in existingSessions) {
if ((type == 0 && session.matches_inbound(body) == 1) || type == 1) {
plaintext = session.decrypt(type, body);
}
}
}
if (plaintext == null && type != 0) {
throw ("No existing sessions found");
}
if (plaintext == null) {
olm.Session newSession = olm.Session();
newSession.create_inbound_from(_olmAccount, senderKey, body);
_olmAccount.remove_one_time_keys(newSession);
storeAPI?.storeClient();
storeOlmSession(senderKey, newSession);
plaintext = newSession.decrypt(type, body);
}
final Map<String, dynamic> plainContent = json.decode(plaintext);
if (plainContent.containsKey("sender") &&
plainContent["sender"] != toDeviceEvent.sender) {
throw ("Message was decrypted but sender doesn't match");
}
if (plainContent.containsKey("recipient") &&
plainContent["recipient"] != userID) {
throw ("Message was decrypted but recipient doesn't match");
}
if (plainContent["recipient_keys"] is Map &&
plainContent["recipient_keys"]["ed25519"] is String &&
plainContent["recipient_keys"]["ed25519"] != fingerprintKey) {
throw ("Message was decrypted but own fingerprint Key doesn't match");
}
return ToDeviceEvent(
content: plainContent["content"],
type: plainContent["type"],
sender: toDeviceEvent.sender,
);
}
/// A map from Curve25519 identity keys to existing olm sessions.
Map<String, List<olm.Session>> get olmSessions => _olmSessions;
Map<String, List<olm.Session>> _olmSessions = {};
void storeOlmSession(String curve25519IdentityKey, olm.Session session) {
if (!_olmSessions.containsKey(curve25519IdentityKey)) {
_olmSessions[curve25519IdentityKey] = [];
}
_olmSessions[curve25519IdentityKey].add(session);
Map<String, List<String>> pickleMap = {};
for (var entry in olmSessions.entries) {
pickleMap[entry.key] = [];
for (olm.Session session in entry.value) {
try {
pickleMap[entry.key].add(session.pickle(userID));
} catch (e) {
print("[LibOlm] Could not pickle olm session: " + e.toString());
}
}
}
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;
// 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);
}
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,
"sender_keys": {"ed25519": fingerprintKey},
"recipient": device.userId,
"recipient_keys": {"ed25519": device.ed25519Key},
};
final olm.EncryptResult encryptResult =
existingSessions.first.encrypt(json.encode(payload));
encryptedMessage["ciphertext"][device.curve25519Key] = {
"type": encryptResult.type,
"body": encryptResult.body,
};
}
// 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>();
}
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",
data: data,
);
}
Future<void> startOutgoingOlmSessions(List<DeviceKeys> deviceKeys,
{bool checkSignature = true}) async {
Map<String, Map<String, String>> requestingKeysFrom = {};
for (DeviceKeys device in deviceKeys) {
if (requestingKeysFrom[device.userId] == null) {
requestingKeysFrom[device.userId] = {};
}
requestingKeysFrom[device.userId][device.deviceId] = "signed_curve25519";
}
final Map<String, dynamic> response = await jsonRequest(
type: HTTPType.POST,
action: "/client/r0/keys/claim",
data: {"timeout": 10000, "one_time_keys": requestingKeysFrom},
);
for (var userKeysEntry in response["one_time_keys"].entries) {
final String userId = userKeysEntry.key;
for (var deviceKeysEntry in userKeysEntry.value.entries) {
final String deviceId = deviceKeysEntry.key;
final String fingerprintKey =
userDeviceKeys[userId].deviceKeys[deviceId].ed25519Key;
final String identityKey =
userDeviceKeys[userId].deviceKeys[deviceId].curve25519Key;
for (Map<String, dynamic> deviceKey in deviceKeysEntry.value.values) {
if (checkSignature &&
checkJsonSignature(fingerprintKey, deviceKey, userId, deviceId) ==
false) {
continue;
}
try {
olm.Session session = olm.Session();
session.create_outbound(_olmAccount, identityKey, deviceKey["key"]);
await storeOlmSession(identityKey, session);
} catch (e) {
print("[LibOlm] Could not create new outbound olm session: " +
e.toString());
}
}
}
}
}
}

View file

@ -22,6 +22,7 @@
*/
import 'dart:async';
import 'dart:convert';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/src/client.dart';
@ -32,7 +33,9 @@ import 'package:famedlysdk/src/sync/room_update.dart';
import 'package:famedlysdk/src/utils/matrix_exception.dart';
import 'package:famedlysdk/src/utils/matrix_file.dart';
import 'package:famedlysdk/src/utils/mx_content.dart';
import 'package:famedlysdk/src/utils/session_key.dart';
import 'package:mime_type/mime_type.dart';
import 'package:olm/olm.dart' as olm;
import './user.dart';
import 'timeline.dart';
@ -78,6 +81,97 @@ class Room {
/// Key-Value store for private account data only visible for this user.
Map<String, RoomAccountData> roomAccountData = {};
olm.OutboundGroupSession get outboundGroupSession => _outboundGroupSession;
olm.OutboundGroupSession _outboundGroupSession;
/// Clears the existing outboundGroupSession, tries to create a new one and
/// stores it as an ingoingGroupSession in the [sessionKeys]. Then sends the
/// new session encrypted with olm to all non-blocked devices using
/// to-device-messaging.
Future<void> createOutboundGroupSession() async {
await clearOutboundGroupSession();
try {
_outboundGroupSession = olm.OutboundGroupSession();
_outboundGroupSession.create();
} catch (e) {
_outboundGroupSession = null;
print("[LibOlm] Unable to create new outboundGroupSession: " +
e.toString());
}
if (_outboundGroupSession == null) return;
await client.storeAPI?.setItem(
"/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session",
_outboundGroupSession.pickle(client.userID));
// Add as an inboundSession to the [sessionKeys].
Map<String, dynamic> rawSession = {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": this.id,
"session_id": _outboundGroupSession.session_id(),
"session_key": _outboundGroupSession.session_key(),
};
setSessionKey(rawSession["session_id"], rawSession);
List<DeviceKeys> deviceKeys = await getUserDeviceKeys();
try {
// TODO: Fix type '_InternalLinkedHashMap<String, dynamic>' is not a subtype of type 'Iterable<dynamic>'
await client.sendToDevice(deviceKeys, "m.room_key", rawSession);
} catch (e) {
print(
"[LibOlm] Unable to send the session key to the participating devices: " +
e.toString());
await clearOutboundGroupSession();
}
return;
}
/// Clears the existing outboundGroupSession.
Future<void> clearOutboundGroupSession() async {
await client.storeAPI?.setItem(
"/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session",
null);
this._outboundGroupSession?.free();
this._outboundGroupSession = null;
return;
}
/// Key-Value store of session ids to the session keys. Only m.megolm.v1.aes-sha2
/// session keys are supported. They are stored as a Map with the following keys:
/// {
/// "algorithm": "m.megolm.v1.aes-sha2",
/// "room_id": "!Cuyf34gef24t:localhost",
/// "session_id": "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ",
/// "session_key": "AgAAAADxKHa9uFxcXzwYoNueL5Xqi69IkD4sni8LlfJL7qNBEY..."
/// }
Map<String, SessionKey> get sessionKeys => _sessionKeys;
Map<String, SessionKey> _sessionKeys = {};
/// Add a new session key to the [sessionKeys].
void setSessionKey(String sessionId, Map<String, dynamic> content) {
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"]);
} catch (e) {
inboundGroupSession = null;
print("[LibOlm] Could not create new InboundGroupSession: " +
e.toString());
}
}
_sessionKeys[sessionId] = SessionKey(
content: content,
inboundGroupSession: inboundGroupSession,
indexes: {},
key: client.userID,
);
client.storeAPI?.setItem(
"/clients/${client.deviceID}/rooms/${this.id}/session_keys",
json.encode(sessionKeys));
}
/// Returns the [Event] for the given [typeKey] and optional [stateKey].
/// If no [stateKey] is provided, it defaults to an empty string.
Event getState(String typeKey, [String stateKey = ""]) =>
@ -86,6 +180,16 @@ class Room {
/// Adds the [state] to this room and overwrites a state with the same
/// typeKey/stateKey key pair if there is one.
void setState(Event state) {
// Check if this is a member change and we need to clear the outboundGroupSession.
if (encrypted &&
outboundGroupSession != null &&
state.type == EventTypes.RoomMember) {
User newUser = state.asUser;
User oldUser = getState("m.room.member", newUser.id)?.asUser;
if (oldUser == null || oldUser.membership != newUser.membership) {
clearOutboundGroupSession();
}
}
if (!states.states.containsKey(state.typeKey)) {
states.states[state.typeKey] = {};
}
@ -280,16 +384,6 @@ class Room {
return resp["event_id"];
}
Future<String> _sendRawEventNow(Map<String, dynamic> content,
{String txid}) async {
if (txid == null) txid = "txid${DateTime.now().millisecondsSinceEpoch}";
final Map<String, dynamic> res = await client.jsonRequest(
type: HTTPType.PUT,
action: "/client/r0/rooms/${id}/send/m.room.message/$txid",
data: content);
return res["event_id"];
}
Future<String> sendTextEvent(String message,
{String txid, Event inReplyTo}) =>
sendEvent({"msgtype": "m.text", "body": message},
@ -404,7 +498,7 @@ class Room {
Future<String> sendEvent(Map<String, dynamic> content,
{String txid, Event inReplyTo}) async {
final String type = "m.room.message";
final String type = this.encrypted ? "m.room.encrypted" : "m.room.message";
// Create new transaction id
String messageID;
@ -423,7 +517,8 @@ class Room {
}
replyText = replyTextLines.join("\n");
content["format"] = "org.matrix.custom.html";
content["formatted_body"] = '<mx-reply><blockquote><a href="https://matrix.to/#/${inReplyTo.room.id}/${inReplyTo.eventId}">In reply to</a> <a href="https://matrix.to/#/${inReplyTo.senderId}">${inReplyTo.senderId}</a><br>${inReplyTo.body}</blockquote></mx-reply>${content["formatted_body"] ?? content["body"]}';
content["formatted_body"] =
'<mx-reply><blockquote><a href="https://matrix.to/#/${inReplyTo.room.id}/${inReplyTo.eventId}">In reply to</a> <a href="https://matrix.to/#/${inReplyTo.senderId}">${inReplyTo.senderId}</a><br>${inReplyTo.body}</blockquote></mx-reply>${content["formatted_body"] ?? content["body"]}';
content["body"] = replyText + "\n\n${content["body"] ?? ""}";
content["m.relates_to"] = {
"m.in_reply_to": {
@ -450,7 +545,11 @@ class Room {
// Send the text and on success, store and display a *sent* event.
try {
final String res = await _sendRawEventNow(content, txid: messageID);
final Map<String, dynamic> response = await client.jsonRequest(
type: HTTPType.PUT,
action: "/client/r0/rooms/${id}/send/$type/$messageID",
data: await encryptGroupMessagePayload(content));
final String res = response["event_id"];
eventUpdate.content["status"] = 1;
eventUpdate.content["unsigned"] = {"transaction_id": messageID};
eventUpdate.content["event_id"] = res;
@ -461,6 +560,7 @@ class Room {
});
return res;
} catch (exception) {
print("[Client] Error while sending: " + exception.toString());
// On error, set status to -1
eventUpdate.content["status"] = -1;
eventUpdate.content["unsigned"] = {"transaction_id": messageID};
@ -704,6 +804,41 @@ class Room {
return;
}
void restoreGroupSessionKeys() async {
// Restore the inbound and outbound session keys
if (client.encryptionEnabled && client.storeAPI != null) {
final String outboundGroupSessionPickle = await client.storeAPI.getItem(
"/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session");
if (outboundGroupSessionPickle != null) {
try {
this._outboundGroupSession = olm.OutboundGroupSession();
this
._outboundGroupSession
.unpickle(client.userID, outboundGroupSessionPickle);
} catch (e) {
this._outboundGroupSession = null;
print("[LibOlm] Unable to unpickle outboundGroupSession: " +
e.toString());
}
}
final String sessionKeysPickle = await client.storeAPI
.getItem("/clients/${client.deviceID}/rooms/${this.id}/session_keys");
if (sessionKeysPickle?.isNotEmpty ?? false) {
final Map<String, dynamic> map = json.decode(sessionKeysPickle);
this._sessionKeys = {};
for (var entry in map.entries) {
try {
this._sessionKeys[entry.key] =
SessionKey.fromJson(entry.value, client.userID);
} catch (e) {
print("[LibOlm] Could not unpickle inboundGroupSession: " +
e.toString());
}
}
}
}
}
/// Returns a Room from a json String which comes normally from the store. If the
/// state are also given, the method will await them.
static Future<Room> getRoomFromTableRow(
@ -725,6 +860,9 @@ class Room {
roomAccountData: {},
);
// Restore the inbound and outbound session keys
await newRoom.restoreGroupSessionKeys();
if (states != null) {
List<Map<String, dynamic>> rawStates = await states;
for (int i = 0; i < rawStates.length; i++) {
@ -753,6 +891,15 @@ class Room {
onTimelineInsertCallback onInsert}) async {
List<Event> events =
client.store != null ? await client.store.getEventList(this) : [];
if (this.encrypted) {
for (int i = 0; i < events.length; i++) {
try {
events[i] = decryptGroupMessage(events[i]);
} catch (e) {
print("[LibOlm] Could not decrypt group message: " + e.toString());
}
}
}
Timeline timeline = Timeline(
room: this,
events: events,
@ -1294,6 +1441,7 @@ class Room {
return;
}
/// Returns all known device keys for all participants in this room.
Future<List<DeviceKeys>> getUserDeviceKeys() async {
List<DeviceKeys> deviceKeys = [];
List<User> users = await requestParticipants();
@ -1308,4 +1456,73 @@ class Room {
}
return deviceKeys;
}
/// Encrypts the given json payload and creates a send-ready m.room.encrypted
/// payload. This will create a new outgoingGroupSession if necessary.
Future<Map<String, dynamic>> encryptGroupMessagePayload(
Map<String, dynamic> payload,
{String type = "m.room.message"}) async {
if (!this.encrypted) return payload;
if (!client.encryptionEnabled) throw ("Encryption is not enabled");
if (this.encryptionAlgorithm != "m.megolm.v1.aes-sha2") {
throw ("Unknown encryption algorithm");
}
if (_outboundGroupSession == null) {
await createOutboundGroupSession();
}
final Map<String, dynamic> payloadContent = {
"content": payload,
"type": type,
"room_id": id,
};
Map<String, dynamic> encryptedPayload = {
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": _outboundGroupSession.encrypt(json.encode(payloadContent)),
"device_id": client.deviceID,
"sender_key": client.identityKey,
"session_id": _outboundGroupSession.session_id(),
};
return encryptedPayload;
}
/// Decrypts the given [event] with one of the available ingoingGroupSessions.
Event decryptGroupMessage(Event event) {
if (!client.encryptionEnabled) throw ("Encryption is not enabled");
if (event.content["algorithm"] != "m.megolm.v1.aes-sha2") {
throw ("Unknown encryption algorithm");
}
final String sessionId = event.content["session_id"];
if (!sessionKeys.containsKey(sessionId)) {
throw ("Unknown session id");
}
final olm.DecryptResult decryptResult = sessionKeys[sessionId]
.inboundGroupSession
.decrypt(event.content["ciphertext"]);
final String messageIndexKey =
event.eventId + event.time.millisecondsSinceEpoch.toString();
if (sessionKeys[sessionId].indexes.containsKey(messageIndexKey) &&
sessionKeys[sessionId].indexes[messageIndexKey] !=
decryptResult.message_index) {
throw ("Invalid message index");
}
sessionKeys[sessionId].indexes[messageIndexKey] =
decryptResult.message_index;
// TODO: The client should check that the sender's fingerprint key matches the keys.ed25519 property of the event which established the Megolm session when marking the event as verified.
final Map<String, dynamic> decryptedPayload =
json.decode(decryptResult.plaintext);
return Event(
content: decryptedPayload["content"],
typeKey: decryptedPayload["type"],
senderId: event.senderId,
eventId: event.eventId,
roomId: event.roomId,
room: event.room,
time: event.time,
unsigned: event.unsigned,
stateKey: event.stateKey,
prevContent: event.prevContent,
status: event.status,
);
}
}

View file

@ -48,9 +48,13 @@ abstract class StoreAPI {
/// Clears all tables from the database.
Future<void> clear();
Future<void> storeUserDeviceKeys(Map<String, DeviceKeysList> userDeviceKeys);
Future<dynamic> getItem(String key);
Future<void> setItem(String key, String value);
Future<Map<String, DeviceKeysList>> getUserDeviceKeys();
Future<void> storeUserDeviceKeys(Map<String, DeviceKeysList> userDeviceKeys);
}
/// Responsible to store all data persistent and to query objects from the

View file

@ -91,6 +91,19 @@ class Timeline {
void _handleEventUpdate(EventUpdate eventUpdate) async {
try {
if (eventUpdate.roomID != room.id) return;
if (eventUpdate.eventType == "m.room.encrypted") {
Event decrypted =
room.decryptGroupMessage(Event.fromJson(eventUpdate.content, room));
eventUpdate = EventUpdate(
eventType: decrypted.typeKey,
content: eventUpdate.content,
type: eventUpdate.type,
roomID: eventUpdate.roomID,
);
eventUpdate.content["content"] = decrypted.content;
}
if (eventUpdate.type == "timeline" || eventUpdate.type == "history") {
// Redaction events are handled as modification for existing events.
if (eventUpdate.eventType == "m.room.redaction") {

View file

@ -45,6 +45,9 @@ class DeviceKeys {
bool verified;
bool blocked;
String get curve25519Key => keys["curve25519:$deviceId"];
String get ed25519Key => keys["ed25519:$deviceId"];
Future<void> setVerified(bool newVerified, Client client) {
verified = newVerified;
return client.storeAPI.storeUserDeviceKeys(client.userDeviceKeys);

View file

@ -0,0 +1,38 @@
import 'dart:convert';
import 'package:olm/olm.dart';
class SessionKey {
Map<String, dynamic> content;
Map<String, int> indexes;
InboundGroupSession inboundGroupSession;
final String key;
SessionKey({this.content, this.inboundGroupSession, this.key, this.indexes});
SessionKey.fromJson(Map<String, dynamic> json, String key) : this.key = key {
content = json['content'] != null
? Map<String, dynamic>.from(json['content'])
: null;
indexes = json['indexes'] != null
? Map<String, int>.from(json['indexes'])
: Map<String, int>();
InboundGroupSession newInboundGroupSession = InboundGroupSession();
newInboundGroupSession.unpickle(key, json['inboundGroupSession']);
inboundGroupSession = newInboundGroupSession;
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = Map<String, dynamic>();
if (this.content != null) {
data['content'] = this.content;
}
if (this.indexes != null) {
data['indexes'] = this.indexes;
}
data['inboundGroupSession'] = this.inboundGroupSession.pickle(this.key);
return data;
}
String toString() => json.encode(this.toJson());
}

View file

@ -0,0 +1,25 @@
class ToDeviceEvent {
String sender;
String type;
Map<String, dynamic> content;
ToDeviceEvent({this.sender, this.type, this.content});
ToDeviceEvent.fromJson(Map<String, dynamic> json) {
sender = json['sender'];
type = json['type'];
content = json['content'] != null
? Map<String, dynamic>.from(json['content'])
: null;
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = Map<String, dynamic>();
data['sender'] = this.sender;
data['type'] = this.type;
if (this.content != null) {
data['content'] = this.content;
}
return data;
}
}

23
prepare.sh Normal file
View file

@ -0,0 +1,23 @@
#!/bin/sh
mkdir js
cd js
curl -O 'https://packages.matrix.org/npm/olm/olm-3.1.4.tgz'
tar xaf olm-3.1.4.tgz
cd ..
if [ -f /usr/lib/x86_64-linux-gnu/libolm.so.3 ]
then
mkdir -p ffi/olm/
ln -sf /usr/lib/x86_64-linux-gnu/libolm.so.3 ffi/olm/libolm.so
else
cd ffi
pushd ffi
git clone --depth 1 https://gitlab.matrix.org/matrix-org/olm.git
cd olm
cmake -DCMAKE_BUILD_TYPE=Release .
cmake --build .
cd ..
fi
pub get

View file

@ -148,6 +148,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.7"
ffi:
dependency: transitive
description:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3"
fixnum:
dependency: transitive
description:
@ -245,7 +252,7 @@ packages:
name: matcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.5"
version: "0.12.6"
meta:
dependency: transitive
description:
@ -281,6 +288,15 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.8"
olm:
dependency: "direct main"
description:
path: "."
ref: "09eb49dbdb1ad9ed71c6bf74562250ecd3d4198b"
resolved-ref: "09eb49dbdb1ad9ed71c6bf74562250ecd3d4198b"
url: "https://gitlab.com/famedly/libraries/dart-olm.git"
source: git
version: "0.0.0"
package_config:
dependency: transitive
description:
@ -427,21 +443,21 @@ packages:
name: test
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.1"
version: "1.11.1"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.8"
version: "0.2.13"
test_core:
dependency: transitive
description:
name: test_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.12"
version: "0.2.18"
timing:
dependency: transitive
description:

View file

@ -12,6 +12,11 @@ dependencies:
mime_type: ^0.2.4
canonical_json: ^1.0.0
olm:
git:
url: https://gitlab.com/famedly/libraries/dart-olm.git
ref: 09eb49dbdb1ad9ed71c6bf74562250ecd3d4198b
dev_dependencies:
test: ^1.0.0
build_runner: ^1.5.2

2
test.sh Normal file
View file

@ -0,0 +1,2 @@
#!/bin/sh -e
pub run test -p vm

View file

@ -22,6 +22,7 @@
*/
import 'dart:async';
import 'dart:convert';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/src/account_data.dart';
@ -35,9 +36,11 @@ import 'package:famedlysdk/src/sync/user_update.dart';
import 'package:famedlysdk/src/utils/matrix_exception.dart';
import 'package:famedlysdk/src/utils/matrix_file.dart';
import 'package:famedlysdk/src/utils/profile.dart';
import 'package:olm/olm.dart' as olm;
import 'package:test/test.dart';
import 'fake_matrix_api.dart';
import 'fake_store.dart';
void main() {
Client matrix;
@ -45,6 +48,12 @@ void main() {
Future<List<RoomUpdate>> roomUpdateListFuture;
Future<List<EventUpdate>> eventUpdateListFuture;
Future<List<UserUpdate>> userUpdateListFuture;
Future<List<ToDeviceEvent>> toDeviceUpdateListFuture;
const String pickledOlmAccount =
"N2v1MkIFGcl0mQpo2OCwSopxPQJ0wnl7oe7PKiT4141AijfdTIhRu+ceXzXKy3Kr00nLqXtRv7kid6hU4a+V0rfJWLL0Y51+3Rp/ORDVnQy+SSeo6Fn4FHcXrxifJEJ0djla5u98fBcJ8BSkhIDmtXRPi5/oJAvpiYn+8zMjFHobOeZUAxYR0VfQ9JzSYBsSovoQ7uFkNks1M4EDUvHtuweStA+EKZvvHZO0SnwRp0Hw7sv8UMYvXw";
const String identityKey = "7rvl3jORJkBiK4XX1e5TnGnqz068XfYJ0W++Ml63rgk";
const String fingerprintKey = "gjL//fyaFHADt9KBADGag8g7F8Up78B/K1zXeiEPLJo";
/// All Tests related to the Login
group("FluffyMatrix", () {
@ -56,6 +65,16 @@ void main() {
roomUpdateListFuture = matrix.onRoomUpdate.stream.toList();
eventUpdateListFuture = matrix.onEvent.stream.toList();
userUpdateListFuture = matrix.onUserEvent.stream.toList();
toDeviceUpdateListFuture = matrix.onToDeviceEvent.stream.toList();
bool olmEnabled = true;
try {
olm.init();
olm.Account();
} catch (_) {
olmEnabled = false;
print("[LibOlm] Failed to load LibOlm: " + _.toString());
}
print("[LibOlm] Enabled: $olmEnabled");
test('Login', () async {
int presenceCounter = 0;
@ -106,13 +125,16 @@ void main() {
Future<dynamic> syncFuture = matrix.onSync.stream.first;
matrix.connect(
newToken: resp["access_token"],
newUserID: resp["user_id"],
newHomeserver: matrix.homeserver,
newDeviceName: "Text Matrix Client",
newDeviceID: resp["device_id"],
newMatrixVersions: matrix.matrixVersions,
newLazyLoadMembers: matrix.lazyLoadMembers);
newToken: resp["access_token"],
newUserID: resp["user_id"],
newHomeserver: matrix.homeserver,
newDeviceName: "Text Matrix Client",
newDeviceID: resp["device_id"],
newMatrixVersions: matrix.matrixVersions,
newLazyLoadMembers: matrix.lazyLoadMembers,
newOlmAccount: pickledOlmAccount,
);
await Future.delayed(Duration(milliseconds: 50));
expect(matrix.accessToken == resp["access_token"], true);
@ -126,6 +148,12 @@ void main() {
expect(loginState, LoginState.logged);
expect(firstSync, true);
expect(matrix.encryptionEnabled, olmEnabled);
if (olmEnabled) {
expect(matrix.pickledOlmAccount, pickledOlmAccount);
expect(matrix.identityKey, identityKey);
expect(matrix.fingerprintKey, fingerprintKey);
}
expect(sync["next_batch"] == matrix.prevBatch, true);
expect(matrix.accountData.length, 3);
@ -135,6 +163,22 @@ void main() {
expect(matrix.directChats, matrix.accountData["m.direct"].content);
expect(matrix.presences.length, 1);
expect(matrix.rooms[1].ephemerals.length, 2);
expect(matrix.rooms[1].sessionKeys.length, 1);
expect(
matrix
.rooms[1]
.sessionKeys["ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU"]
.content["session_key"],
"AgAAAAAQcQ6XrFJk6Prm8FikZDqfry/NbDz8Xw7T6e+/9Yf/q3YHIPEQlzv7IZMNcYb51ifkRzFejVvtphS7wwG2FaXIp4XS2obla14iKISR0X74ugB2vyb1AydIHE/zbBQ1ic5s3kgjMFlWpu/S3FQCnCrv+DPFGEt3ERGWxIl3Bl5X53IjPyVkz65oljz2TZESwz0GH/QFvyOOm8ci0q/gceaF3S7Dmafg3dwTKYwcA5xkcc+BLyrLRzB6Hn+oMAqSNSscnm4mTeT5zYibIhrzqyUTMWr32spFtI9dNR/RFSzfCw");
if (olmEnabled) {
expect(
matrix
.rooms[1]
.sessionKeys["ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU"]
.inboundGroupSession !=
null,
true);
}
expect(matrix.rooms[1].typingUsers.length, 1);
expect(matrix.rooms[1].typingUsers[0].id, "@alice:example.com");
expect(matrix.rooms[1].roomAccountData.length, 3);
@ -330,7 +374,7 @@ void main() {
List<UserUpdate> eventUpdateList = await userUpdateListFuture;
expect(eventUpdateList.length, 5);
expect(eventUpdateList.length, 4);
expect(eventUpdateList[0].eventType, "m.presence");
expect(eventUpdateList[0].type, "presence");
@ -342,6 +386,17 @@ void main() {
expect(eventUpdateList[2].type, "account_data");
});
test('To Device Update Test', () async {
await matrix.onToDeviceEvent.close();
List<ToDeviceEvent> eventUpdateList = await toDeviceUpdateListFuture;
expect(eventUpdateList.length, 2);
expect(eventUpdateList[0].type, "m.new_device");
expect(eventUpdateList[1].type, "m.room_key");
});
test('Login', () async {
matrix = Client("testclient", debug: true);
matrix.httpClient = FakeMatrixApi();
@ -417,6 +472,147 @@ void main() {
expect(profile.content["displayname"], profile.displayname);
});
test('signJson', () {
if (matrix.encryptionEnabled) {
expect(matrix.fingerprintKey.isNotEmpty, true);
expect(matrix.identityKey.isNotEmpty, true);
Map<String, dynamic> payload = {
"unsigned": {
"foo": "bar",
},
"auth": {
"success": true,
"mxid": "@john.doe:example.com",
"profile": {
"display_name": "John Doe",
"three_pids": [
{"medium": "email", "address": "john.doe@example.org"},
{"medium": "msisdn", "address": "123456789"}
]
}
}
};
Map<String, dynamic> payloadWithoutUnsigned = Map.from(payload);
payloadWithoutUnsigned.remove("unsigned");
expect(
matrix.checkJsonSignature(
matrix.fingerprintKey, payload, matrix.userID, matrix.deviceID),
false);
expect(
matrix.checkJsonSignature(matrix.fingerprintKey,
payloadWithoutUnsigned, matrix.userID, matrix.deviceID),
false);
payload = matrix.signJson(payload);
payloadWithoutUnsigned = matrix.signJson(payloadWithoutUnsigned);
expect(payload["signatures"], payloadWithoutUnsigned["signatures"]);
print(payload["signatures"]);
expect(
matrix.checkJsonSignature(
matrix.fingerprintKey, payload, matrix.userID, matrix.deviceID),
true);
expect(
matrix.checkJsonSignature(matrix.fingerprintKey,
payloadWithoutUnsigned, matrix.userID, matrix.deviceID),
true);
}
});
test('Track oneTimeKeys', () async {
if (matrix.encryptionEnabled) {
DateTime last = matrix.lastTimeKeysUploaded ?? DateTime.now();
matrix.handleSync({
"device_one_time_keys_count": {"signed_curve25519": 49}
});
await Future.delayed(Duration(milliseconds: 50));
expect(
matrix.lastTimeKeysUploaded.millisecondsSinceEpoch >
last.millisecondsSinceEpoch,
true);
}
});
test('Test invalidate outboundGroupSessions', () async {
if (matrix.encryptionEnabled) {
expect(matrix.rooms[1].outboundGroupSession == null, true);
await matrix.rooms[1].createOutboundGroupSession();
expect(matrix.rooms[1].outboundGroupSession != null, true);
matrix.handleSync({
"device_lists": {
"changed": [
"@alice:example.com",
],
"left": [
"@bob:example.com",
],
}
});
await Future.delayed(Duration(milliseconds: 50));
expect(matrix.rooms[1].outboundGroupSession == null, true);
}
});
test('Test invalidate outboundGroupSessions', () async {
if (matrix.encryptionEnabled) {
expect(matrix.rooms[1].outboundGroupSession == null, true);
await matrix.rooms[1].createOutboundGroupSession();
expect(matrix.rooms[1].outboundGroupSession != null, true);
matrix.handleSync({
"rooms": {
"join": {
"!726s6s6q:example.com": {
"state": {
"events": [
{
"content": {"membership": "leave"},
"event_id": "143273582443PhrSn:example.org",
"origin_server_ts": 1432735824653,
"room_id": "!726s6s6q:example.com",
"sender": "@alice:example.com",
"state_key": "@alice:example.com",
"type": "m.room.member"
}
]
}
}
}
}
});
await Future.delayed(Duration(milliseconds: 50));
expect(matrix.rooms[1].outboundGroupSession == null, true);
}
});
DeviceKeys deviceKeys = DeviceKeys.fromJson({
"user_id": "@alice:example.com",
"device_id": "JLAFKJWSCS",
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
"keys": {
"curve25519:JLAFKJWSCS": "3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI",
"ed25519:JLAFKJWSCS": "lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI"
},
"signatures": {
"@alice:example.com": {
"ed25519:JLAFKJWSCS":
"dSO80A01XiigH3uBiDVx/EjzaoycHcjq9lfQX0uWsqxl2giMIiSPR8a4d291W1ihKJL/a+myXS367WT6NAIcBA"
}
}
});
test('startOutgoingOlmSessions', () async {
expect(matrix.olmSessions.length, 0);
if (olmEnabled) {
await matrix
.startOutgoingOlmSessions([deviceKeys], checkSignature: false);
expect(matrix.olmSessions.length, 1);
expect(matrix.olmSessions.entries.first.key,
"3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI");
}
});
test('sendToDevice', () async {
await matrix.sendToDevice(
[deviceKeys],
"m.message",
{
"msgtype": "m.text",
"body": "Hello world",
});
});
test('Logout when token is unknown', () async {
Future<LoginState> loginStateFuture =
matrix.onLoginStateChanged.stream.first;
@ -432,5 +628,63 @@ void main() {
expect(state, LoginState.loggedOut);
expect(matrix.isLogged(), false);
});
test('Test the fake store api', () async {
Client client1 = Client("testclient", debug: true);
client1.httpClient = FakeMatrixApi();
FakeStore fakeStore = FakeStore(client1, {});
client1.storeAPI = fakeStore;
client1.connect(
newToken: "abc123",
newUserID: "@test:fakeServer.notExisting",
newHomeserver: "https://fakeServer.notExisting",
newDeviceName: "Text Matrix Client",
newDeviceID: "GHTYAJCE",
newMatrixVersions: [
"r0.0.1",
"r0.1.0",
"r0.2.0",
"r0.3.0",
"r0.4.0",
"r0.5.0"
],
newLazyLoadMembers: true,
newOlmAccount: pickledOlmAccount,
);
await Future.delayed(Duration(milliseconds: 50));
String sessionKey;
if (client1.encryptionEnabled) {
await client1.rooms[1].createOutboundGroupSession();
sessionKey = client1.rooms[1].outboundGroupSession.session_key();
}
expect(client1.isLogged(), true);
expect(client1.rooms.length, 2);
Client client2 = Client("testclient", debug: true);
client2.httpClient = FakeMatrixApi();
client2.storeAPI = FakeStore(client2, fakeStore.storeMap);
await Future.delayed(Duration(milliseconds: 100));
expect(client2.isLogged(), true);
expect(client2.accessToken, client1.accessToken);
expect(client2.userID, client1.userID);
expect(client2.homeserver, client1.homeserver);
expect(client2.deviceID, client1.deviceID);
expect(client2.deviceName, client1.deviceName);
expect(client2.matrixVersions, client1.matrixVersions);
expect(client2.lazyLoadMembers, client1.lazyLoadMembers);
if (client2.encryptionEnabled) {
expect(client2.pickledOlmAccount, client1.pickledOlmAccount);
expect(json.encode(client2.rooms[1].sessionKeys[sessionKey]),
json.encode(client1.rooms[1].sessionKeys[sessionKey]));
expect(client2.rooms[1].id, client1.rooms[1].id);
expect(client2.rooms[1].outboundGroupSession.session_key(), sessionKey);
}
});
});
}

View file

@ -57,6 +57,9 @@ class FakeMatrixApi extends MockClient {
if (res.containsKey("errcode")) {
return Response(json.encode(res), 405);
}
} else if (method == "PUT" &&
action.contains("/client/r0/sendToDevice/m.room.encrypted/")) {
return Response(json.encode({}), 200);
} else if (method == "GET" &&
action.contains("/client/r0/rooms/") &&
action.contains("/state/m.room.member/")) {
@ -335,7 +338,18 @@ class FakeMatrixApi extends MockClient {
"device_id": "XYZABCDE",
"rooms": ["!726s6s6q:example.com"]
}
}
},
{
"sender": "@alice:example.com",
"content": {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!726s6s6q:example.com",
"session_id": "ciM/JWTPrmiWPPZNkRLDPQYf9AW/I46bxyLSr+Bx5oU",
"session_key":
"AgAAAAAQcQ6XrFJk6Prm8FikZDqfry/NbDz8Xw7T6e+/9Yf/q3YHIPEQlzv7IZMNcYb51ifkRzFejVvtphS7wwG2FaXIp4XS2obla14iKISR0X74ugB2vyb1AydIHE/zbBQ1ic5s3kgjMFlWpu/S3FQCnCrv+DPFGEt3ERGWxIl3Bl5X53IjPyVkz65oljz2TZESwz0GH/QFvyOOm8ci0q/gceaF3S7Dmafg3dwTKYwcA5xkcc+BLyrLRzB6Hn+oMAqSNSscnm4mTeT5zYibIhrzqyUTMWr32spFtI9dNR/RFSzfCw"
},
"type": "m.room_key"
},
]
},
"rooms": {
@ -768,6 +782,30 @@ class FakeMatrixApi extends MockClient {
{"available": true},
},
"POST": {
"/client/r0/keys/claim": (var req) => {
"failures": {},
"one_time_keys": {
"@alice:example.com": {
"JLAFKJWSCS": {
"signed_curve25519:AAAAHg": {
"key": "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs",
"signatures": {
"@alice:example.com": {
"ed25519:JLAFKJWSCS":
"FLWxXqGbwrb8SM3Y795eB6OA8bwBcoMZFXBqnTn58AYWZSqiD45tlBVcDa2L7RwdKXebW/VzDlnfVJ+9jok1Bw"
}
}
}
}
}
}
},
"/client/r0/keys/upload": (var req) => {
"one_time_key_counts": {
"curve25519": 10,
"signed_curve25519": 100,
}
},
"/client/r0/keys/query": (var req) => {
"failures": {},
"device_keys": {

89
test/fake_store.dart Normal file
View file

@ -0,0 +1,89 @@
import 'dart:convert';
import 'package:famedlysdk/famedlysdk.dart';
class FakeStore implements StoreAPI {
/// Whether this is a simple store which only stores the client credentials and
/// end to end encryption stuff or the whole sync payloads.
final bool extended = false;
Map<String, dynamic> storeMap = {};
/// Link back to the client.
Client client;
FakeStore(this.client, this.storeMap) {
_init();
}
_init() async {
final credentialsStr = await getItem(client.clientName);
if (credentialsStr == null || credentialsStr.isEmpty) {
client.onLoginStateChanged.add(LoginState.loggedOut);
return;
}
print("[Matrix] Restoring account credentials");
final Map<String, dynamic> credentials = json.decode(credentialsStr);
client.connect(
newDeviceID: credentials["deviceID"],
newDeviceName: credentials["deviceName"],
newHomeserver: credentials["homeserver"],
newLazyLoadMembers: credentials["lazyLoadMembers"],
newMatrixVersions: List<String>.from(credentials["matrixVersions"]),
newToken: credentials["token"],
newUserID: credentials["userID"],
newPrevBatch: credentials["prev_batch"],
newOlmAccount: credentials["olmAccount"],
);
}
/// Will be automatically called when the client is logged in successfully.
Future<void> storeClient() async {
final Map<String, dynamic> credentials = {
"deviceID": client.deviceID,
"deviceName": client.deviceName,
"homeserver": client.homeserver,
"lazyLoadMembers": client.lazyLoadMembers,
"matrixVersions": client.matrixVersions,
"token": client.accessToken,
"userID": client.userID,
"olmAccount": client.pickledOlmAccount,
};
await setItem(client.clientName, json.encode(credentials));
return;
}
/// Clears all tables from the database.
Future<void> clear() async {
storeMap = {};
return;
}
Future<dynamic> getItem(String key) async {
return storeMap[key];
}
Future<void> setItem(String key, String value) async {
storeMap[key] = value;
return;
}
String get _UserDeviceKeysKey => "${client.clientName}.user_device_keys";
Future<Map<String, DeviceKeysList>> getUserDeviceKeys() async {
final deviceKeysListString = await getItem(_UserDeviceKeysKey);
if (deviceKeysListString == null) return {};
Map<String, dynamic> rawUserDeviceKeys = json.decode(deviceKeysListString);
Map<String, DeviceKeysList> userDeviceKeys = {};
for (final entry in rawUserDeviceKeys.entries) {
userDeviceKeys[entry.key] = DeviceKeysList.fromJson(entry.value);
}
return userDeviceKeys;
}
Future<void> storeUserDeviceKeys(
Map<String, DeviceKeysList> userDeviceKeys) async {
await setItem(_UserDeviceKeysKey, json.encode(userDeviceKeys));
}
}

View file

@ -362,5 +362,79 @@ void main() {
.add(matrix.accountData["m.push_rules"].content["global"]["room"][0]);
expect(room.pushRuleState, PushRuleState.dont_notify);
});
test('Enable encryption', () async {
room.setState(
Event(
senderId: "@alice:test.abc",
typeKey: "m.room.encryption",
roomId: room.id,
room: room,
eventId: "12345",
time: DateTime.now(),
content: {
"algorithm": "m.megolm.v1.aes-sha2",
"rotation_period_ms": 604800000,
"rotation_period_msgs": 100
},
stateKey: ""),
);
expect(room.encrypted, true);
expect(room.encryptionAlgorithm, "m.megolm.v1.aes-sha2");
expect(room.outboundGroupSession, null);
});
test('createOutboundGroupSession', () async {
if (!room.client.encryptionEnabled) return;
await room.createOutboundGroupSession();
expect(room.outboundGroupSession != null, true);
expect(room.outboundGroupSession.session_id().isNotEmpty, true);
expect(
room.sessionKeys.containsKey(room.outboundGroupSession.session_id()),
true);
expect(
room.sessionKeys[room.outboundGroupSession.session_id()]
.content["session_key"],
room.outboundGroupSession.session_key());
expect(
room.sessionKeys[room.outboundGroupSession.session_id()].indexes
.length,
0);
});
test('clearOutboundGroupSession', () async {
if (!room.client.encryptionEnabled) return;
await room.clearOutboundGroupSession();
expect(room.outboundGroupSession == null, true);
});
test('encryptGroupMessagePayload and decryptGroupMessage', () async {
if (!room.client.encryptionEnabled) return;
final Map<String, dynamic> payload = {
"msgtype": "m.text",
"body": "Hello world",
};
final Map<String, dynamic> encryptedPayload =
await room.encryptGroupMessagePayload(payload);
expect(encryptedPayload["algorithm"], "m.megolm.v1.aes-sha2");
expect(encryptedPayload["ciphertext"].isNotEmpty, true);
expect(encryptedPayload["device_id"], room.client.deviceID);
expect(encryptedPayload["sender_key"], room.client.identityKey);
expect(encryptedPayload["session_id"],
room.outboundGroupSession.session_id());
Event encryptedEvent = Event(
content: encryptedPayload,
typeKey: "m.room.encrypted",
senderId: room.client.userID,
eventId: "1234",
roomId: room.id,
room: room,
time: DateTime.now(),
);
Event decryptedEvent = room.decryptGroupMessage(encryptedEvent);
expect(decryptedEvent.typeKey, "m.room.message");
expect(decryptedEvent.content, payload);
});
});
}

View file

@ -0,0 +1,131 @@
import 'package:famedlysdk/famedlysdk.dart';
import '../test/fake_store.dart';
void main() => test();
const String homeserver = "https://matrix.test.famedly.de";
const String testUserA = "@tick:test.famedly.de";
const String testPasswordA = "test";
const String testUserB = "@trick:test.famedly.de";
const String testPasswordB = "test";
const String testMessage = "Hello world";
const String testMessage2 = "Hello moon";
const String testMessage3 = "Hello sun";
void test() async {
print("++++ Login $testUserA ++++");
Client testClientA = Client("TestClient", debug: false);
testClientA.storeAPI = FakeStore(testClientA, Map<String, dynamic>());
await testClientA.checkServer(homeserver);
await testClientA.login(testUserA, testPasswordA);
print("++++ Login $testUserB ++++");
Client testClientB = Client("TestClient", debug: false);
testClientB.storeAPI = FakeStore(testClientB, Map<String, dynamic>());
await testClientB.checkServer(homeserver);
await testClientB.login(testUserB, testPasswordA);
print("++++ ($testUserA) Leave all rooms ++++");
while (testClientA.rooms.isNotEmpty) {
Room room = testClientA.rooms.first;
if (room.canonicalAlias?.isNotEmpty ?? false) {
break;
}
await room.leave();
await room.forget();
}
print("++++ ($testUserB) Leave all rooms ++++");
if (testClientB.rooms.isNotEmpty) {
Room room = testClientB.rooms.first;
await room.leave();
await room.forget();
}
if (testClientB.rooms.isNotEmpty) {
Room room = testClientB.rooms.first;
await room.leave();
await room.forget();
}
print("++++ ($testUserA) Create room and invite $testUserB ++++");
await testClientA.createRoom(invite: [User(testUserB)]);
await Future.delayed(Duration(seconds: 1));
Room room = testClientA.rooms.first;
assert(room != null);
final String roomId = room.id;
print("++++ ($testUserB) Join room ++++");
Room inviteRoom = testClientB.getRoomById(roomId);
await inviteRoom.join();
await Future.delayed(Duration(seconds: 1));
assert(inviteRoom.membership == Membership.join);
print("++++ ($testUserA) Enable encryption ++++");
assert(room.encrypted == false);
await room.enableEncryption();
await Future.delayed(Duration(seconds: 5));
assert(room.encrypted == true);
assert(room.outboundGroupSession == null);
print("++++ ($testUserA) Check known olm devices ++++");
assert(testClientA.userDeviceKeys.containsKey(testUserB));
assert(testClientA.userDeviceKeys[testUserB].deviceKeys
.containsKey(testClientB.deviceID));
print("++++ ($testUserA) Send encrypted message: '$testMessage' ++++");
await room.sendTextEvent(testMessage);
await Future.delayed(Duration(seconds: 5));
assert(room.outboundGroupSession != null);
final String currentSessionIdA = room.outboundGroupSession.session_id();
assert(room.sessionKeys.containsKey(room.outboundGroupSession.session_id()));
assert(testClientA.olmSessions[testClientB.identityKey].length == 1);
assert(testClientB.olmSessions[testClientA.identityKey].length == 1);
assert(inviteRoom.sessionKeys
.containsKey(room.outboundGroupSession.session_id()));
assert(room.lastMessage == testMessage);
assert(inviteRoom.lastMessage == testMessage);
print(
"++++ ($testUserB) Received decrypted message: '${inviteRoom.lastMessage}' ++++");
print("++++ ($testUserA) Send again encrypted message: '$testMessage2' ++++");
await room.sendTextEvent(testMessage2);
await Future.delayed(Duration(seconds: 5));
assert(testClientA.olmSessions[testClientB.identityKey].length == 1);
assert(testClientB.olmSessions[testClientA.identityKey].length == 1);
assert(room.outboundGroupSession.session_id() == currentSessionIdA);
assert(inviteRoom.sessionKeys
.containsKey(room.outboundGroupSession.session_id()));
assert(room.lastMessage == testMessage2);
assert(inviteRoom.lastMessage == testMessage2);
print(
"++++ ($testUserB) Received decrypted message: '${inviteRoom.lastMessage}' ++++");
print("++++ ($testUserB) Send again encrypted message: '$testMessage3' ++++");
await inviteRoom.sendTextEvent(testMessage3);
await Future.delayed(Duration(seconds: 5));
assert(testClientA.olmSessions[testClientB.identityKey].length == 1);
assert(testClientB.olmSessions[testClientA.identityKey].length == 1);
assert(room.outboundGroupSession.session_id() == currentSessionIdA);
assert(inviteRoom.outboundGroupSession != null);
assert(inviteRoom.sessionKeys
.containsKey(inviteRoom.outboundGroupSession.session_id()));
assert(room.sessionKeys
.containsKey(inviteRoom.outboundGroupSession.session_id()));
assert(inviteRoom.lastMessage == testMessage3);
assert(room.lastMessage == testMessage3);
print(
"++++ ($testUserA) Received decrypted message: '${room.lastMessage}' ++++");
print("++++ Logout $testUserA and $testUserB ++++");
await room.leave();
await room.forget();
await inviteRoom.leave();
await inviteRoom.forget();
await Future.delayed(Duration(seconds: 1));
await testClientA.jsonRequest(
type: HTTPType.POST, action: "/client/r0/logout/all");
await testClientB.jsonRequest(
type: HTTPType.POST, action: "/client/r0/logout/all");
testClientA = null;
testClientB = null;
}