Merge branch 'sdk-enhance-add-dart-olm' into 'master'
[SDK] Add dart-olm library and update CI See merge request famedly/famedlysdk!182
This commit is contained in:
commit
4921c4c31b
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,6 +8,7 @@
|
|||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
native/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
|
|
@ -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*%)/'
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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);
|
||||
|
|
38
lib/src/utils/session_key.dart
Normal file
38
lib/src/utils/session_key.dart
Normal 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());
|
||||
}
|
25
lib/src/utils/to_device_event.dart
Normal file
25
lib/src/utils/to_device_event.dart
Normal 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
23
prepare.sh
Normal 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
|
24
pubspec.lock
24
pubspec.lock
|
@ -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:
|
||||
|
|
|
@ -12,7 +12,12 @@ 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
|
||||
pedantic: ^1.5.0 # DO NOT UPDATE AS THIS WOULD CAUSE FLUTTER TO FAIL
|
||||
pedantic: ^1.5.0 # DO NOT UPDATE AS THIS WOULD CAUSE FLUTTER TO FAIL
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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
89
test/fake_store.dart
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
131
test_driver/famedlysdk_test.dart
Normal file
131
test_driver/famedlysdk_test.dart
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue