1094 lines
40 KiB
Dart
1094 lines
40 KiB
Dart
/*
|
|
* Famedly Matrix SDK
|
|
* Copyright (C) 2019, 2020, 2021 Famedly GmbH
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:matrix/encryption/utils/base64_unpadded.dart';
|
|
import 'package:matrix/encryption/utils/stored_inbound_group_session.dart';
|
|
import 'package:olm/olm.dart' as olm;
|
|
import 'package:collection/collection.dart';
|
|
|
|
import './encryption.dart';
|
|
import './utils/outbound_group_session.dart';
|
|
import './utils/session_key.dart';
|
|
import '../matrix.dart';
|
|
import '../src/utils/run_in_root.dart';
|
|
|
|
const megolmKey = EventTypes.MegolmBackup;
|
|
|
|
class KeyManager {
|
|
final Encryption encryption;
|
|
Client get client => encryption.client;
|
|
final outgoingShareRequests = <String, KeyManagerKeyShareRequest>{};
|
|
final incomingShareRequests = <String, KeyManagerKeyShareRequest>{};
|
|
final _inboundGroupSessions = <String, Map<String, SessionKey>>{};
|
|
final _outboundGroupSessions = <String, OutboundGroupSession>{};
|
|
final Set<String> _loadedOutboundGroupSessions = <String>{};
|
|
final Set<String> _requestedSessionIds = <String>{};
|
|
|
|
KeyManager(this.encryption) {
|
|
encryption.ssss.setValidator(megolmKey, (String secret) async {
|
|
final keyObj = olm.PkDecryption();
|
|
try {
|
|
final info = await getRoomKeysBackupInfo(false);
|
|
if (info.algorithm !=
|
|
BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2) {
|
|
return false;
|
|
}
|
|
return keyObj.init_with_private_key(base64decodeUnpadded(secret)) ==
|
|
info.authData['public_key'];
|
|
} catch (_) {
|
|
return false;
|
|
} finally {
|
|
keyObj.free();
|
|
}
|
|
});
|
|
encryption.ssss.setCacheCallback(megolmKey, (String secret) {
|
|
// we got a megolm key cached, clear our requested keys and try to re-decrypt
|
|
// last events
|
|
_requestedSessionIds.clear();
|
|
for (final room in client.rooms) {
|
|
final lastEvent = room.lastEvent;
|
|
if (lastEvent != null &&
|
|
lastEvent.type == EventTypes.Encrypted &&
|
|
lastEvent.content['can_request_session'] == true) {
|
|
try {
|
|
maybeAutoRequest(room.id, lastEvent.content['session_id'],
|
|
lastEvent.content['sender_key']);
|
|
} catch (_) {
|
|
// dispose
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
bool get enabled => encryption.ssss.isSecret(megolmKey);
|
|
|
|
/// clear all cached inbound group sessions. useful for testing
|
|
void clearInboundGroupSessions() {
|
|
_inboundGroupSessions.clear();
|
|
}
|
|
|
|
void setInboundGroupSession(
|
|
String roomId,
|
|
String sessionId,
|
|
String senderKey,
|
|
Map<String, dynamic> content, {
|
|
bool forwarded = false,
|
|
Map<String, String>? senderClaimedKeys,
|
|
bool uploaded = false,
|
|
Map<String, Map<String, int>>? allowedAtIndex,
|
|
}) {
|
|
final senderClaimedKeys_ = senderClaimedKeys ?? <String, String>{};
|
|
final allowedAtIndex_ = allowedAtIndex ?? <String, Map<String, int>>{};
|
|
final userId = client.userID;
|
|
if (userId == null) return;
|
|
|
|
if (!senderClaimedKeys_.containsKey('ed25519')) {
|
|
final device = client.getUserDeviceKeysByCurve25519Key(senderKey);
|
|
if (device != null && device.ed25519Key != null) {
|
|
senderClaimedKeys_['ed25519'] = device.ed25519Key!;
|
|
}
|
|
}
|
|
final oldSession =
|
|
getInboundGroupSession(roomId, sessionId, senderKey, otherRooms: false);
|
|
if (content['algorithm'] != AlgorithmTypes.megolmV1AesSha2) {
|
|
return;
|
|
}
|
|
late olm.InboundGroupSession inboundGroupSession;
|
|
try {
|
|
inboundGroupSession = olm.InboundGroupSession();
|
|
if (forwarded) {
|
|
inboundGroupSession.import_session(content['session_key']);
|
|
} else {
|
|
inboundGroupSession.create(content['session_key']);
|
|
}
|
|
} catch (e, s) {
|
|
inboundGroupSession.free();
|
|
Logs().e('[LibOlm] Could not create new InboundGroupSession', e, s);
|
|
return;
|
|
}
|
|
final newSession = SessionKey(
|
|
content: content,
|
|
inboundGroupSession: inboundGroupSession,
|
|
indexes: {},
|
|
roomId: roomId,
|
|
sessionId: sessionId,
|
|
key: userId,
|
|
senderKey: senderKey,
|
|
senderClaimedKeys: senderClaimedKeys_,
|
|
allowedAtIndex: allowedAtIndex_,
|
|
);
|
|
final oldFirstIndex =
|
|
oldSession?.inboundGroupSession?.first_known_index() ?? 0;
|
|
final newFirstIndex = newSession.inboundGroupSession!.first_known_index();
|
|
if (oldSession == null ||
|
|
newFirstIndex < oldFirstIndex ||
|
|
(oldFirstIndex == newFirstIndex &&
|
|
newSession.forwardingCurve25519KeyChain.length <
|
|
oldSession.forwardingCurve25519KeyChain.length)) {
|
|
// use new session
|
|
oldSession?.dispose();
|
|
} else {
|
|
// we are gonna keep our old session
|
|
newSession.dispose();
|
|
return;
|
|
}
|
|
|
|
final roomInboundGroupSessions =
|
|
_inboundGroupSessions[roomId] ??= <String, SessionKey>{};
|
|
roomInboundGroupSessions[sessionId] = newSession;
|
|
if (!client.isLogged() || client.encryption == null) {
|
|
return;
|
|
}
|
|
client.database
|
|
?.storeInboundGroupSession(
|
|
roomId,
|
|
sessionId,
|
|
inboundGroupSession.pickle(userId),
|
|
json.encode(content),
|
|
json.encode({}),
|
|
json.encode(allowedAtIndex_),
|
|
senderKey,
|
|
json.encode(senderClaimedKeys_),
|
|
)
|
|
.then((_) {
|
|
if (!client.isLogged() || client.encryption == null) {
|
|
return;
|
|
}
|
|
if (uploaded) {
|
|
client.database?.markInboundGroupSessionAsUploaded(roomId, sessionId);
|
|
} else {
|
|
_haveKeysToUpload = true;
|
|
}
|
|
});
|
|
final room = client.getRoomById(roomId);
|
|
if (room != null) {
|
|
// attempt to decrypt the last event
|
|
final event = room.getState(EventTypes.Encrypted);
|
|
if (event != null && event.content['session_id'] == sessionId) {
|
|
room.setState(encryption.decryptRoomEventSync(roomId, event));
|
|
}
|
|
// and finally broadcast the new session
|
|
room.onSessionKeyReceived.add(sessionId);
|
|
}
|
|
}
|
|
|
|
SessionKey? getInboundGroupSession(
|
|
String roomId, String sessionId, String senderKey,
|
|
{bool otherRooms = true}) {
|
|
final sess = _inboundGroupSessions[roomId]?[sessionId];
|
|
if (sess != null) {
|
|
if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) {
|
|
return null;
|
|
}
|
|
return sess;
|
|
}
|
|
if (!otherRooms) {
|
|
return null;
|
|
}
|
|
// search if this session id is *somehow* found in another room
|
|
for (final val in _inboundGroupSessions.values) {
|
|
final sess = val[sessionId];
|
|
if (sess != null) {
|
|
if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) {
|
|
return null;
|
|
}
|
|
return sess;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Attempt auto-request for a key
|
|
void maybeAutoRequest(String roomId, String sessionId, String senderKey) {
|
|
final room = client.getRoomById(roomId);
|
|
final requestIdent = '$roomId|$sessionId|$senderKey';
|
|
if (room != null &&
|
|
!_requestedSessionIds.contains(requestIdent) &&
|
|
!client.isUnknownSession) {
|
|
// do e2ee recovery
|
|
_requestedSessionIds.add(requestIdent);
|
|
runInRoot(
|
|
() => request(room, sessionId, senderKey, onlineKeyBackupOnly: true));
|
|
}
|
|
}
|
|
|
|
/// Loads an inbound group session
|
|
Future<SessionKey?> loadInboundGroupSession(
|
|
String roomId, String sessionId, String senderKey) async {
|
|
final sess = _inboundGroupSessions[roomId]?[sessionId];
|
|
if (sess != null) {
|
|
if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) {
|
|
return null; // sender keys do not match....better not do anything
|
|
}
|
|
return sess; // nothing to do
|
|
}
|
|
final session =
|
|
await client.database?.getInboundGroupSession(roomId, sessionId);
|
|
if (session == null) return null;
|
|
final userID = client.userID;
|
|
if (userID == null) return null;
|
|
final dbSess = SessionKey.fromDb(session, userID);
|
|
final roomInboundGroupSessions =
|
|
_inboundGroupSessions[roomId] ??= <String, SessionKey>{};
|
|
if (!dbSess.isValid ||
|
|
dbSess.senderKey.isEmpty ||
|
|
dbSess.senderKey != senderKey) {
|
|
return null;
|
|
}
|
|
roomInboundGroupSessions[sessionId] = dbSess;
|
|
return sess;
|
|
}
|
|
|
|
Map<String, Map<String, bool>> _getDeviceKeyIdMap(
|
|
List<DeviceKeys> deviceKeys) {
|
|
final deviceKeyIds = <String, Map<String, bool>>{};
|
|
for (final device in deviceKeys) {
|
|
final deviceId = device.deviceId;
|
|
if (deviceId == null) {
|
|
Logs().w('[KeyManager] ignoring device without deviceid');
|
|
continue;
|
|
}
|
|
final userDeviceKeyIds = deviceKeyIds[device.userId] ??= <String, bool>{};
|
|
userDeviceKeyIds[deviceId] = !device.encryptToDevice;
|
|
}
|
|
return deviceKeyIds;
|
|
}
|
|
|
|
/// clear all cached inbound group sessions. useful for testing
|
|
void clearOutboundGroupSessions() {
|
|
_outboundGroupSessions.clear();
|
|
}
|
|
|
|
/// Clears the existing outboundGroupSession but first checks if the participating
|
|
/// devices have been changed. Returns false if the session has not been cleared because
|
|
/// it wasn't necessary. Otherwise returns true.
|
|
Future<bool> clearOrUseOutboundGroupSession(String roomId,
|
|
{bool wipe = false, bool use = true}) async {
|
|
final room = client.getRoomById(roomId);
|
|
final sess = getOutboundGroupSession(roomId);
|
|
if (room == null || sess == null || sess.outboundGroupSession == null) {
|
|
return true;
|
|
}
|
|
|
|
if (!wipe) {
|
|
// first check if it needs to be rotated
|
|
final encryptionContent =
|
|
room.getState(EventTypes.Encryption)?.parsedRoomEncryptionContent;
|
|
final maxMessages = encryptionContent?.rotationPeriodMsgs ?? 100;
|
|
final maxAge = encryptionContent?.rotationPeriodMs ??
|
|
604800000; // default of one week
|
|
if ((sess.sentMessages ?? maxMessages) >= maxMessages ||
|
|
sess.creationTime
|
|
.add(Duration(milliseconds: maxAge))
|
|
.isBefore(DateTime.now())) {
|
|
wipe = true;
|
|
}
|
|
}
|
|
|
|
final inboundSess = await loadInboundGroupSession(room.id,
|
|
sess.outboundGroupSession!.session_id(), encryption.identityKey!);
|
|
if (inboundSess == null) {
|
|
wipe = true;
|
|
}
|
|
|
|
if (!wipe) {
|
|
// next check if the devices in the room changed
|
|
final devicesToReceive = <DeviceKeys>[];
|
|
final newDeviceKeys = await room.getUserDeviceKeys();
|
|
final newDeviceKeyIds = _getDeviceKeyIdMap(newDeviceKeys);
|
|
// first check for user differences
|
|
final oldUserIds = Set.from(sess.devices.keys);
|
|
final newUserIds = Set.from(newDeviceKeyIds.keys);
|
|
if (oldUserIds.difference(newUserIds).isNotEmpty) {
|
|
// a user left the room, we must wipe the session
|
|
wipe = true;
|
|
} else {
|
|
final newUsers = newUserIds.difference(oldUserIds);
|
|
if (newUsers.isNotEmpty) {
|
|
// new user! Gotta send the megolm session to them
|
|
devicesToReceive
|
|
.addAll(newDeviceKeys.where((d) => newUsers.contains(d.userId)));
|
|
}
|
|
// okay, now we must test all the individual user devices, if anything new got blocked
|
|
// or if we need to send to any new devices.
|
|
// for this it is enough if we iterate over the old user Ids, as the new ones already have the needed keys in the list.
|
|
// we also know that all the old user IDs appear in the old one, else we have already wiped the session
|
|
for (final userId in oldUserIds) {
|
|
final oldBlockedDevices = sess.devices.containsKey(userId)
|
|
? Set.from(sess.devices[userId]!.entries
|
|
.where((e) => e.value)
|
|
.map((e) => e.key))
|
|
: <String>{};
|
|
final newBlockedDevices = newDeviceKeyIds.containsKey(userId)
|
|
? Set.from(newDeviceKeyIds[userId]!
|
|
.entries
|
|
.where((e) => e.value)
|
|
.map((e) => e.key))
|
|
: <String>{};
|
|
// we don't really care about old devices that got dropped (deleted), we only care if new ones got added and if new ones got blocked
|
|
// check if new devices got blocked
|
|
if (newBlockedDevices.difference(oldBlockedDevices).isNotEmpty) {
|
|
wipe = true;
|
|
break;
|
|
}
|
|
// and now add all the new devices!
|
|
final oldDeviceIds = sess.devices.containsKey(userId)
|
|
? Set.from(sess.devices[userId]!.entries
|
|
.where((e) => !e.value)
|
|
.map((e) => e.key))
|
|
: <String>{};
|
|
final newDeviceIds = newDeviceKeyIds.containsKey(userId)
|
|
? Set.from(newDeviceKeyIds[userId]!
|
|
.entries
|
|
.where((e) => !e.value)
|
|
.map((e) => e.key))
|
|
: <String>{};
|
|
final newDevices = newDeviceIds.difference(oldDeviceIds);
|
|
if (newDeviceIds.isNotEmpty) {
|
|
devicesToReceive.addAll(newDeviceKeys.where(
|
|
(d) => d.userId == userId && newDevices.contains(d.deviceId)));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!wipe) {
|
|
if (!use) {
|
|
return false;
|
|
}
|
|
// okay, we use the outbound group session!
|
|
sess.devices = newDeviceKeyIds;
|
|
final rawSession = <String, dynamic>{
|
|
'algorithm': AlgorithmTypes.megolmV1AesSha2,
|
|
'room_id': room.id,
|
|
'session_id': sess.outboundGroupSession!.session_id(),
|
|
'session_key': sess.outboundGroupSession!.session_key(),
|
|
};
|
|
try {
|
|
devicesToReceive.removeWhere((k) => !k.encryptToDevice);
|
|
if (devicesToReceive.isNotEmpty) {
|
|
// update allowedAtIndex
|
|
for (final device in devicesToReceive) {
|
|
inboundSess!.allowedAtIndex[device.userId] ??= <String, int>{};
|
|
if (!inboundSess.allowedAtIndex[device.userId]!
|
|
.containsKey(device.curve25519Key) ||
|
|
inboundSess.allowedAtIndex[device.userId]![
|
|
device.curve25519Key]! >
|
|
sess.outboundGroupSession!.message_index()) {
|
|
inboundSess
|
|
.allowedAtIndex[device.userId]![device.curve25519Key!] =
|
|
sess.outboundGroupSession!.message_index();
|
|
}
|
|
}
|
|
await client.database?.updateInboundGroupSessionAllowedAtIndex(
|
|
json.encode(inboundSess!.allowedAtIndex),
|
|
room.id,
|
|
sess.outboundGroupSession!.session_id());
|
|
// send out the key
|
|
await client.sendToDeviceEncryptedChunked(
|
|
devicesToReceive, EventTypes.RoomKey, rawSession);
|
|
}
|
|
} catch (e, s) {
|
|
Logs().e(
|
|
'[LibOlm] Unable to re-send the session key at later index to new devices',
|
|
e,
|
|
s);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
sess.dispose();
|
|
_outboundGroupSessions.remove(roomId);
|
|
await client.database?.removeOutboundGroupSession(roomId);
|
|
return true;
|
|
}
|
|
|
|
/// Store an outbound group session in the database
|
|
Future<void> storeOutboundGroupSession(
|
|
String roomId, OutboundGroupSession sess) async {
|
|
final userID = client.userID;
|
|
if (userID == null) return;
|
|
await client.database?.storeOutboundGroupSession(
|
|
roomId,
|
|
sess.outboundGroupSession!.pickle(userID),
|
|
json.encode(sess.devices),
|
|
sess.creationTime.millisecondsSinceEpoch);
|
|
}
|
|
|
|
final Map<String, Future<OutboundGroupSession>>
|
|
_pendingNewOutboundGroupSessions = {};
|
|
|
|
/// Creates an outbound group session for a given room id
|
|
Future<OutboundGroupSession> createOutboundGroupSession(String roomId) async {
|
|
final sess = _pendingNewOutboundGroupSessions[roomId];
|
|
if (sess != null) {
|
|
return sess;
|
|
}
|
|
_pendingNewOutboundGroupSessions[roomId] =
|
|
_createOutboundGroupSession(roomId);
|
|
await _pendingNewOutboundGroupSessions[roomId];
|
|
return _pendingNewOutboundGroupSessions.remove(roomId)!;
|
|
}
|
|
|
|
/// Prepares an outbound group session for a given room ID. That is, load it from
|
|
/// the database, cycle it if needed and create it if absent.
|
|
Future<void> prepareOutboundGroupSession(String roomId) async {
|
|
if (getOutboundGroupSession(roomId) == null) {
|
|
await loadOutboundGroupSession(roomId);
|
|
}
|
|
await clearOrUseOutboundGroupSession(roomId, use: false);
|
|
if (getOutboundGroupSession(roomId) == null) {
|
|
await createOutboundGroupSession(roomId);
|
|
}
|
|
}
|
|
|
|
Future<OutboundGroupSession> _createOutboundGroupSession(
|
|
String roomId) async {
|
|
await clearOrUseOutboundGroupSession(roomId, wipe: true);
|
|
final room = client.getRoomById(roomId);
|
|
if (room == null) {
|
|
throw Exception(
|
|
'Tried to create a megolm session in a non-existing room ($roomId)!');
|
|
}
|
|
final userID = client.userID;
|
|
if (userID == null) {
|
|
throw Exception(
|
|
'Tried to create a megolm session without being logged in!');
|
|
}
|
|
|
|
final deviceKeys = await room.getUserDeviceKeys();
|
|
final deviceKeyIds = _getDeviceKeyIdMap(deviceKeys);
|
|
deviceKeys.removeWhere((k) => !k.encryptToDevice);
|
|
final outboundGroupSession = olm.OutboundGroupSession();
|
|
try {
|
|
outboundGroupSession.create();
|
|
} catch (e, s) {
|
|
outboundGroupSession.free();
|
|
Logs().e('[LibOlm] Unable to create new outboundGroupSession', e, s);
|
|
rethrow;
|
|
}
|
|
final rawSession = <String, dynamic>{
|
|
'algorithm': AlgorithmTypes.megolmV1AesSha2,
|
|
'room_id': room.id,
|
|
'session_id': outboundGroupSession.session_id(),
|
|
'session_key': outboundGroupSession.session_key(),
|
|
};
|
|
final allowedAtIndex = <String, Map<String, int>>{};
|
|
for (final device in deviceKeys) {
|
|
if (!device.isValid) {
|
|
Logs().e('Skipping invalid device');
|
|
continue;
|
|
}
|
|
allowedAtIndex[device.userId] ??= <String, int>{};
|
|
allowedAtIndex[device.userId]![device.curve25519Key!] =
|
|
outboundGroupSession.message_index();
|
|
}
|
|
setInboundGroupSession(
|
|
roomId, rawSession['session_id'], encryption.identityKey!, rawSession,
|
|
allowedAtIndex: allowedAtIndex);
|
|
final sess = OutboundGroupSession(
|
|
devices: deviceKeyIds,
|
|
creationTime: DateTime.now(),
|
|
outboundGroupSession: outboundGroupSession,
|
|
key: userID,
|
|
);
|
|
try {
|
|
await client.sendToDeviceEncryptedChunked(
|
|
deviceKeys, EventTypes.RoomKey, rawSession);
|
|
await storeOutboundGroupSession(roomId, sess);
|
|
_outboundGroupSessions[roomId] = sess;
|
|
} catch (e, s) {
|
|
Logs().e(
|
|
'[LibOlm] Unable to send the session key to the participating devices',
|
|
e,
|
|
s);
|
|
sess.dispose();
|
|
rethrow;
|
|
}
|
|
return sess;
|
|
}
|
|
|
|
/// Get an outbound group session for a room id
|
|
OutboundGroupSession? getOutboundGroupSession(String roomId) {
|
|
return _outboundGroupSessions[roomId];
|
|
}
|
|
|
|
/// Load an outbound group session from database
|
|
Future<void> loadOutboundGroupSession(String roomId) async {
|
|
final database = client.database;
|
|
final userID = client.userID;
|
|
if (_loadedOutboundGroupSessions.contains(roomId) ||
|
|
_outboundGroupSessions.containsKey(roomId) ||
|
|
database == null ||
|
|
userID == null) {
|
|
return; // nothing to do
|
|
}
|
|
_loadedOutboundGroupSessions.add(roomId);
|
|
final sess = await database.getOutboundGroupSession(
|
|
roomId,
|
|
userID,
|
|
);
|
|
if (sess == null || !sess.isValid) {
|
|
return;
|
|
}
|
|
_outboundGroupSessions[roomId] = sess;
|
|
}
|
|
|
|
Future<bool> isCached() async {
|
|
if (!enabled) {
|
|
return false;
|
|
}
|
|
return (await encryption.ssss.getCached(megolmKey)) != null;
|
|
}
|
|
|
|
GetRoomKeysVersionCurrentResponse? _roomKeysVersionCache;
|
|
DateTime? _roomKeysVersionCacheDate;
|
|
Future<GetRoomKeysVersionCurrentResponse> getRoomKeysBackupInfo(
|
|
[bool useCache = true]) async {
|
|
if (_roomKeysVersionCache != null &&
|
|
_roomKeysVersionCacheDate != null &&
|
|
useCache &&
|
|
DateTime.now()
|
|
.subtract(Duration(minutes: 5))
|
|
.isBefore(_roomKeysVersionCacheDate!)) {
|
|
return _roomKeysVersionCache!;
|
|
}
|
|
_roomKeysVersionCache = await client.getRoomKeysVersionCurrent();
|
|
_roomKeysVersionCacheDate = DateTime.now();
|
|
return _roomKeysVersionCache!;
|
|
}
|
|
|
|
Future<void> loadFromResponse(RoomKeys keys) async {
|
|
if (!(await isCached())) {
|
|
return;
|
|
}
|
|
final privateKey =
|
|
base64decodeUnpadded((await encryption.ssss.getCached(megolmKey))!);
|
|
final decryption = olm.PkDecryption();
|
|
final info = await getRoomKeysBackupInfo();
|
|
String backupPubKey;
|
|
try {
|
|
backupPubKey = decryption.init_with_private_key(privateKey);
|
|
|
|
if (info.algorithm != BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2 ||
|
|
info.authData['public_key'] != backupPubKey) {
|
|
return;
|
|
}
|
|
for (final roomEntry in keys.rooms.entries) {
|
|
final roomId = roomEntry.key;
|
|
for (final sessionEntry in roomEntry.value.sessions.entries) {
|
|
final sessionId = sessionEntry.key;
|
|
final session = sessionEntry.value;
|
|
final sessionData = session.sessionData;
|
|
Map<String, dynamic>? decrypted;
|
|
try {
|
|
decrypted = json.decode(decryption.decrypt(sessionData['ephemeral'],
|
|
sessionData['mac'], sessionData['ciphertext']));
|
|
} catch (e, s) {
|
|
Logs().e('[LibOlm] Error decrypting room key', e, s);
|
|
}
|
|
if (decrypted != null) {
|
|
decrypted['session_id'] = sessionId;
|
|
decrypted['room_id'] = roomId;
|
|
setInboundGroupSession(
|
|
roomId, sessionId, decrypted['sender_key'], decrypted,
|
|
forwarded: true,
|
|
senderClaimedKeys: decrypted['sender_claimed_keys'] != null
|
|
? Map<String, String>.from(
|
|
decrypted['sender_claimed_keys']!)
|
|
: <String, String>{},
|
|
uploaded: true);
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
decryption.free();
|
|
}
|
|
}
|
|
|
|
Future<void> loadSingleKey(String roomId, String sessionId) async {
|
|
final info = await getRoomKeysBackupInfo();
|
|
final ret =
|
|
await client.getRoomKeysBySessionId(roomId, sessionId, info.version);
|
|
final keys = RoomKeys.fromJson({
|
|
'rooms': {
|
|
roomId: {
|
|
'sessions': {
|
|
sessionId: ret.toJson(),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await loadFromResponse(keys);
|
|
}
|
|
|
|
/// Request a certain key from another device
|
|
Future<void> request(
|
|
Room room,
|
|
String sessionId,
|
|
String senderKey, {
|
|
bool tryOnlineBackup = true,
|
|
bool onlineKeyBackupOnly = false,
|
|
}) async {
|
|
if (tryOnlineBackup && await isCached()) {
|
|
// let's first check our online key backup store thingy...
|
|
final hadPreviously =
|
|
getInboundGroupSession(room.id, sessionId, senderKey) != null;
|
|
try {
|
|
await loadSingleKey(room.id, sessionId);
|
|
} catch (err, stacktrace) {
|
|
if (err is MatrixException && err.errcode == 'M_NOT_FOUND') {
|
|
Logs().i(
|
|
'[KeyManager] Key not in online key backup, requesting it from other devices...');
|
|
} else {
|
|
Logs().e('[KeyManager] Failed to access online key backup', err,
|
|
stacktrace);
|
|
}
|
|
}
|
|
// TODO: also don't request from others if we have an index of 0 now
|
|
if (!hadPreviously &&
|
|
getInboundGroupSession(room.id, sessionId, senderKey) != null) {
|
|
return; // we managed to load the session from online backup, no need to care about it now
|
|
}
|
|
}
|
|
if (onlineKeyBackupOnly) {
|
|
return; // we only want to do the online key backup
|
|
}
|
|
try {
|
|
// while we just send the to-device event to '*', we still need to save the
|
|
// devices themself to know where to send the cancel to after receiving a reply
|
|
final devices = await room.getUserDeviceKeys();
|
|
final requestId = client.generateUniqueTransactionId();
|
|
final request = KeyManagerKeyShareRequest(
|
|
requestId: requestId,
|
|
devices: devices,
|
|
room: room,
|
|
sessionId: sessionId,
|
|
senderKey: senderKey,
|
|
);
|
|
final userList = await room.requestParticipants();
|
|
await client.sendToDevicesOfUserIds(
|
|
userList.map<String>((u) => u.id).toSet(),
|
|
EventTypes.RoomKeyRequest,
|
|
{
|
|
'action': 'request',
|
|
'body': {
|
|
'algorithm': AlgorithmTypes.megolmV1AesSha2,
|
|
'room_id': room.id,
|
|
'sender_key': senderKey,
|
|
'session_id': sessionId,
|
|
},
|
|
'request_id': requestId,
|
|
'requesting_device_id': client.deviceID,
|
|
},
|
|
);
|
|
outgoingShareRequests[request.requestId] = request;
|
|
} catch (e, s) {
|
|
Logs().e('[Key Manager] Sending key verification request failed', e, s);
|
|
}
|
|
}
|
|
|
|
bool _isUploadingKeys = false;
|
|
bool _haveKeysToUpload = true;
|
|
Future<void> backgroundTasks() async {
|
|
final database = client.database;
|
|
final userID = client.userID;
|
|
if (_isUploadingKeys || database == null || userID == null) {
|
|
return;
|
|
}
|
|
_isUploadingKeys = true;
|
|
try {
|
|
if (!_haveKeysToUpload || !(await isCached())) {
|
|
return; // we can't backup anyways
|
|
}
|
|
final dbSessions = await database.getInboundGroupSessionsToUpload();
|
|
if (dbSessions.isEmpty) {
|
|
_haveKeysToUpload = false;
|
|
return; // nothing to do
|
|
}
|
|
final privateKey =
|
|
base64decodeUnpadded((await encryption.ssss.getCached(megolmKey))!);
|
|
// decryption is needed to calculate the public key and thus see if the claimed information is in fact valid
|
|
final decryption = olm.PkDecryption();
|
|
final info = await getRoomKeysBackupInfo(false);
|
|
String backupPubKey;
|
|
try {
|
|
backupPubKey = decryption.init_with_private_key(privateKey);
|
|
|
|
if (info.algorithm !=
|
|
BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2 ||
|
|
info.authData['public_key'] != backupPubKey) {
|
|
return;
|
|
}
|
|
final args = _GenerateUploadKeysArgs(
|
|
pubkey: backupPubKey,
|
|
dbSessions: <_DbInboundGroupSessionBundle>[],
|
|
userId: userID,
|
|
);
|
|
// we need to calculate verified beforehand, as else we pass a closure to an isolate
|
|
// with 500 keys they do, however, noticably block the UI, which is why we give brief async suspentions in here
|
|
// so that the event loop can progress
|
|
var i = 0;
|
|
for (final dbSession in dbSessions) {
|
|
final device =
|
|
client.getUserDeviceKeysByCurve25519Key(dbSession.senderKey);
|
|
args.dbSessions.add(_DbInboundGroupSessionBundle(
|
|
dbSession: dbSession,
|
|
verified: device?.verified ?? false,
|
|
));
|
|
i++;
|
|
if (i > 10) {
|
|
await Future.delayed(Duration(milliseconds: 1));
|
|
i = 0;
|
|
}
|
|
}
|
|
final roomKeys =
|
|
await client.runInBackground<RoomKeys, _GenerateUploadKeysArgs>(
|
|
_generateUploadKeys, args);
|
|
Logs().i('[Key Manager] Uploading ${dbSessions.length} room keys...');
|
|
// upload the payload...
|
|
await client.putRoomKeys(info.version, roomKeys);
|
|
// and now finally mark all the keys as uploaded
|
|
// no need to optimze this, as we only run it so seldomly and almost never with many keys at once
|
|
for (final dbSession in dbSessions) {
|
|
await database.markInboundGroupSessionAsUploaded(
|
|
dbSession.roomId, dbSession.sessionId);
|
|
}
|
|
} finally {
|
|
decryption.free();
|
|
}
|
|
} catch (e, s) {
|
|
Logs().e('[Key Manager] Error uploading room keys', e, s);
|
|
} finally {
|
|
_isUploadingKeys = false;
|
|
}
|
|
}
|
|
|
|
/// Handle an incoming to_device event that is related to key sharing
|
|
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
|
if (event.type == EventTypes.RoomKeyRequest) {
|
|
if (!(event.content['request_id'] is String)) {
|
|
return; // invalid event
|
|
}
|
|
if (event.content['action'] == 'request') {
|
|
// we are *receiving* a request
|
|
Logs().i(
|
|
'[KeyManager] Received key sharing request from ${event.sender}:${event.content['requesting_device_id']}...');
|
|
if (!event.content.containsKey('body')) {
|
|
Logs().i('[KeyManager] No body, doing nothing');
|
|
return; // no body
|
|
}
|
|
final device = client.userDeviceKeys[event.sender]
|
|
?.deviceKeys[event.content['requesting_device_id']];
|
|
if (device == null) {
|
|
Logs().i('[KeyManager] Device not found, doing nothing');
|
|
return; // device not found
|
|
}
|
|
if (device.userId == client.userID &&
|
|
device.deviceId == client.deviceID) {
|
|
Logs().i('[KeyManager] Request is by ourself, ignoring');
|
|
return; // ignore requests by ourself
|
|
}
|
|
final room = client.getRoomById(event.content['body']['room_id']);
|
|
if (room == null) {
|
|
Logs().i('[KeyManager] Unknown room, ignoring');
|
|
return; // unknown room
|
|
}
|
|
final sessionId = event.content['body']['session_id'];
|
|
final senderKey = event.content['body']['sender_key'];
|
|
// okay, let's see if we have this session at all
|
|
final session =
|
|
await loadInboundGroupSession(room.id, sessionId, senderKey);
|
|
if (session == null) {
|
|
Logs().i('[KeyManager] Unknown session, ignoring');
|
|
return; // we don't have this session anyways
|
|
}
|
|
final request = KeyManagerKeyShareRequest(
|
|
requestId: event.content['request_id'],
|
|
devices: [device],
|
|
room: room,
|
|
sessionId: sessionId,
|
|
senderKey: senderKey,
|
|
);
|
|
if (incomingShareRequests.containsKey(request.requestId)) {
|
|
Logs().i('[KeyManager] Already processed this request, ignoring');
|
|
return; // we don't want to process one and the same request multiple times
|
|
}
|
|
incomingShareRequests[request.requestId] = request;
|
|
final roomKeyRequest =
|
|
RoomKeyRequest.fromToDeviceEvent(event, this, request);
|
|
if (device.userId == client.userID &&
|
|
device.verified &&
|
|
!device.blocked) {
|
|
Logs().i('[KeyManager] All checks out, forwarding key...');
|
|
// alright, we can forward the key
|
|
await roomKeyRequest.forwardKey();
|
|
} else if (device.encryptToDevice &&
|
|
session.allowedAtIndex
|
|
.tryGet<Map<String, dynamic>>(device.userId)
|
|
?.tryGet(device.curve25519Key!) !=
|
|
null) {
|
|
// if we know the user may see the message, then we can just forward the key.
|
|
// we do not need to check if the device is verified, just if it is not blocked,
|
|
// as that is the logic we already initially try to send out the room keys.
|
|
final index =
|
|
session.allowedAtIndex[device.userId]![device.curve25519Key]!;
|
|
Logs().i(
|
|
'[KeyManager] Valid foreign request, forwarding key at index $index...');
|
|
await roomKeyRequest.forwardKey(index);
|
|
} else {
|
|
Logs()
|
|
.i('[KeyManager] Asking client, if the key should be forwarded');
|
|
client.onRoomKeyRequest
|
|
.add(roomKeyRequest); // let the client handle this
|
|
}
|
|
} else if (event.content['action'] == 'request_cancellation') {
|
|
// we got told to cancel an incoming request
|
|
if (!incomingShareRequests.containsKey(event.content['request_id'])) {
|
|
return; // we don't know this request anyways
|
|
}
|
|
// alright, let's just cancel this request
|
|
final request = incomingShareRequests[event.content['request_id']]!;
|
|
request.canceled = true;
|
|
incomingShareRequests.remove(request.requestId);
|
|
}
|
|
} else if (event.type == EventTypes.ForwardedRoomKey) {
|
|
// we *received* an incoming key request
|
|
final encryptedContent = event.encryptedContent;
|
|
if (encryptedContent == null) {
|
|
return; // event wasn't encrypted, this is a security risk
|
|
}
|
|
final request = outgoingShareRequests.values.firstWhereOrNull((r) =>
|
|
r.room.id == event.content['room_id'] &&
|
|
r.sessionId == event.content['session_id'] &&
|
|
r.senderKey == event.content['sender_key']);
|
|
if (request == null || request.canceled) {
|
|
return; // no associated request found or it got canceled
|
|
}
|
|
final device = request.devices.firstWhereOrNull((d) =>
|
|
d.userId == event.sender &&
|
|
d.curve25519Key == encryptedContent['sender_key']);
|
|
if (device == null) {
|
|
return; // someone we didn't send our request to replied....better ignore this
|
|
}
|
|
// we add the sender key to the forwarded key chain
|
|
if (!(event.content['forwarding_curve25519_key_chain'] is List)) {
|
|
event.content['forwarding_curve25519_key_chain'] = <String>[];
|
|
}
|
|
event.content['forwarding_curve25519_key_chain']
|
|
.add(encryptedContent['sender_key']);
|
|
// TODO: verify that the keys work to decrypt a message
|
|
// alright, all checks out, let's go ahead and store this session
|
|
setInboundGroupSession(
|
|
request.room.id, request.sessionId, request.senderKey, event.content,
|
|
forwarded: true,
|
|
senderClaimedKeys: {
|
|
'ed25519': event.content['sender_claimed_ed25519_key'],
|
|
});
|
|
request.devices.removeWhere(
|
|
(k) => k.userId == device.userId && k.deviceId == device.deviceId);
|
|
outgoingShareRequests.remove(request.requestId);
|
|
// send cancel to all other devices
|
|
if (request.devices.isEmpty) {
|
|
return; // no need to send any cancellation
|
|
}
|
|
// Send with send-to-device messaging
|
|
final sendToDeviceMessage = {
|
|
'action': 'request_cancellation',
|
|
'request_id': request.requestId,
|
|
'requesting_device_id': client.deviceID,
|
|
};
|
|
final data = <String, Map<String, Map<String, dynamic>>>{};
|
|
for (final device in request.devices) {
|
|
final userData = data[device.userId] ??= {};
|
|
userData[device.deviceId!] = sendToDeviceMessage;
|
|
}
|
|
await client.sendToDevice(
|
|
EventTypes.RoomKeyRequest,
|
|
client.generateUniqueTransactionId(),
|
|
data,
|
|
);
|
|
} else if (event.type == EventTypes.RoomKey) {
|
|
Logs().v(
|
|
'[KeyManager] Received room key with session ${event.content['session_id']}');
|
|
final encryptedContent = event.encryptedContent;
|
|
if (encryptedContent == null) {
|
|
Logs().v('[KeyManager] not encrypted, ignoring...');
|
|
return; // the event wasn't encrypted, this is a security risk;
|
|
}
|
|
final String roomId = event.content['room_id'];
|
|
final String sessionId = event.content['session_id'];
|
|
final sender_ed25519 = client.userDeviceKeys[event.sender]
|
|
?.deviceKeys[event.content['requesting_device_id']]?.ed25519Key;
|
|
if (sender_ed25519 != null) {
|
|
event.content['sender_claimed_ed25519_key'] = sender_ed25519;
|
|
}
|
|
Logs().v('[KeyManager] Keeping room key');
|
|
setInboundGroupSession(
|
|
roomId, sessionId, encryptedContent['sender_key'], event.content,
|
|
forwarded: false);
|
|
}
|
|
}
|
|
|
|
void dispose() {
|
|
for (final sess in _outboundGroupSessions.values) {
|
|
sess.dispose();
|
|
}
|
|
for (final entries in _inboundGroupSessions.values) {
|
|
for (final sess in entries.values) {
|
|
sess.dispose();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class KeyManagerKeyShareRequest {
|
|
final String requestId;
|
|
final List<DeviceKeys> devices;
|
|
final Room room;
|
|
final String sessionId;
|
|
final String senderKey;
|
|
bool canceled;
|
|
|
|
KeyManagerKeyShareRequest(
|
|
{required this.requestId,
|
|
List<DeviceKeys>? devices,
|
|
required this.room,
|
|
required this.sessionId,
|
|
required this.senderKey,
|
|
this.canceled = false})
|
|
: devices = devices ?? [];
|
|
}
|
|
|
|
class RoomKeyRequest extends ToDeviceEvent {
|
|
KeyManager keyManager;
|
|
KeyManagerKeyShareRequest request;
|
|
RoomKeyRequest.fromToDeviceEvent(
|
|
ToDeviceEvent toDeviceEvent, this.keyManager, this.request)
|
|
: super(
|
|
sender: toDeviceEvent.sender,
|
|
content: toDeviceEvent.content,
|
|
type: toDeviceEvent.type);
|
|
|
|
Room get room => request.room;
|
|
|
|
DeviceKeys get requestingDevice => request.devices.first;
|
|
|
|
Future<void> forwardKey([int? index]) async {
|
|
if (request.canceled) {
|
|
keyManager.incomingShareRequests.remove(request.requestId);
|
|
return; // request is canceled, don't send anything
|
|
}
|
|
final room = this.room;
|
|
final session = await keyManager.loadInboundGroupSession(
|
|
room.id, request.sessionId, request.senderKey);
|
|
if (session?.inboundGroupSession == null) {
|
|
Logs().v("[KeyManager] Not forwarding key we don't have");
|
|
return;
|
|
}
|
|
|
|
final message = session!.content.copy();
|
|
message['forwarding_curve25519_key_chain'] =
|
|
List<String>.from(session.forwardingCurve25519KeyChain);
|
|
|
|
message['sender_key'] =
|
|
(session.senderKey.isNotEmpty) ? session.senderKey : request.senderKey;
|
|
message['sender_claimed_ed25519_key'] =
|
|
session.senderClaimedKeys['ed25519'] ??
|
|
(session.forwardingCurve25519KeyChain.isEmpty
|
|
? keyManager.encryption.fingerprintKey
|
|
: null);
|
|
message['session_key'] = session.inboundGroupSession!.export_session(
|
|
index ?? session.inboundGroupSession!.first_known_index());
|
|
// send the actual reply of the key back to the requester
|
|
await keyManager.client.sendToDeviceEncrypted(
|
|
[requestingDevice],
|
|
EventTypes.ForwardedRoomKey,
|
|
message,
|
|
);
|
|
keyManager.incomingShareRequests.remove(request.requestId);
|
|
}
|
|
}
|
|
|
|
RoomKeys _generateUploadKeys(_GenerateUploadKeysArgs args) {
|
|
final enc = olm.PkEncryption();
|
|
try {
|
|
enc.set_recipient_key(args.pubkey);
|
|
// first we generate the payload to upload all the session keys in this chunk
|
|
final roomKeys = RoomKeys(rooms: {});
|
|
for (final dbSession in args.dbSessions) {
|
|
final sess = SessionKey.fromDb(dbSession.dbSession, args.userId);
|
|
if (!sess.isValid) {
|
|
continue;
|
|
}
|
|
// create the room if it doesn't exist
|
|
final roomKeyBackup =
|
|
roomKeys.rooms[sess.roomId] ??= RoomKeyBackup(sessions: {});
|
|
// generate the encrypted content
|
|
final payload = <String, dynamic>{
|
|
'algorithm': AlgorithmTypes.megolmV1AesSha2,
|
|
'forwarding_curve25519_key_chain': sess.forwardingCurve25519KeyChain,
|
|
'sender_key': sess.senderKey,
|
|
'sender_clencaimed_keys': sess.senderClaimedKeys,
|
|
'session_key': sess.inboundGroupSession!
|
|
.export_session(sess.inboundGroupSession!.first_known_index()),
|
|
};
|
|
// encrypt the content
|
|
final encrypted = enc.encrypt(json.encode(payload));
|
|
// fetch the device, if available...
|
|
//final device = args.client.getUserDeviceKeysByCurve25519Key(sess.senderKey);
|
|
// aaaand finally add the session key to our payload
|
|
roomKeyBackup.sessions[sess.sessionId] = KeyBackupData(
|
|
firstMessageIndex: sess.inboundGroupSession!.first_known_index(),
|
|
forwardedCount: sess.forwardingCurve25519KeyChain.length,
|
|
isVerified: dbSession.verified, //device?.verified ?? false,
|
|
sessionData: {
|
|
'ephemeral': encrypted.ephemeral,
|
|
'ciphertext': encrypted.ciphertext,
|
|
'mac': encrypted.mac,
|
|
},
|
|
);
|
|
}
|
|
return roomKeys;
|
|
} catch (e, s) {
|
|
Logs().e('[Key Manager] Error generating payload', e, s);
|
|
rethrow;
|
|
} finally {
|
|
enc.free();
|
|
}
|
|
}
|
|
|
|
class _DbInboundGroupSessionBundle {
|
|
_DbInboundGroupSessionBundle(
|
|
{required this.dbSession, required this.verified});
|
|
|
|
StoredInboundGroupSession dbSession;
|
|
bool verified;
|
|
}
|
|
|
|
class _GenerateUploadKeysArgs {
|
|
_GenerateUploadKeysArgs(
|
|
{required this.pubkey, required this.dbSessions, required this.userId});
|
|
|
|
String pubkey;
|
|
List<_DbInboundGroupSessionBundle> dbSessions;
|
|
String userId;
|
|
}
|