/*
* 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 .
*/
import 'dart:convert';
import 'package:canonical_json/canonical_json.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:olm/olm.dart' as olm;
import '../encryption/utils/json_signature_check_extension.dart';
import '../src/utils/run_in_root.dart';
import 'encryption.dart';
import 'utils/olm_session.dart';
class OlmManager {
final Encryption encryption;
Client get client => encryption.client;
olm.Account? _olmAccount;
/// Returns the base64 encoded keys to store them in a store.
/// This String should **never** leave the device!
String? get pickledOlmAccount =>
enabled ? _olmAccount!.pickle(client.userID!) : null;
String? get fingerprintKey =>
enabled ? json.decode(_olmAccount!.identity_keys())['ed25519'] : null;
String? get identityKey =>
enabled ? json.decode(_olmAccount!.identity_keys())['curve25519'] : null;
bool get enabled => _olmAccount != null;
OlmManager(this.encryption);
/// A map from Curve25519 identity keys to existing olm sessions.
Map> get olmSessions => _olmSessions;
final Map> _olmSessions = {};
// NOTE(Nico): On initial login we pass null to create a new account
Future init(String? olmAccount) async {
if (olmAccount == null) {
try {
await olm.init();
_olmAccount = olm.Account();
_olmAccount!.create();
if (!await uploadKeys(uploadDeviceKeys: true, updateDatabase: false)) {
throw ('Upload key failed');
}
} catch (_) {
_olmAccount?.free();
_olmAccount = null;
rethrow;
}
} else {
try {
await olm.init();
_olmAccount = olm.Account();
_olmAccount!.unpickle(client.userID!, olmAccount);
} catch (_) {
_olmAccount?.free();
_olmAccount = null;
rethrow;
}
}
}
/// Adds a signature to this json from this olm account and returns the signed
/// json.
Map signJson(Map payload) {
if (!enabled) throw ('Encryption is disabled');
final Map? unsigned = payload['unsigned'];
final Map? signatures = payload['signatures'];
payload.remove('unsigned');
payload.remove('signatures');
final canonical = canonicalJson.encode(payload);
final signature = _olmAccount!.sign(String.fromCharCodes(canonical));
if (signatures != null) {
payload['signatures'] = signatures;
} else {
payload['signatures'] = {};
}
if (!payload['signatures'].containsKey(client.userID)) {
payload['signatures'][client.userID] = {};
}
payload['signatures'][client.userID]['ed25519:${client.deviceID}'] =
signature;
if (unsigned != null) {
payload['unsigned'] = unsigned;
}
return payload;
}
String signString(String s) {
return _olmAccount!.sign(s);
}
/// Checks the signature of a signed json object.
@deprecated
bool checkJsonSignature(String key, Map signedJson,
String userId, String deviceId) {
if (!enabled) throw ('Encryption is disabled');
final Map? 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 canonical = canonicalJson.encode(signedJson);
final message = String.fromCharCodes(canonical);
var isValid = false;
final olmutil = olm.Utility();
try {
olmutil.ed25519_verify(key, message, signature);
isValid = true;
} catch (e, s) {
isValid = false;
Logs().w('[LibOlm] Signature check failed', e, s);
} finally {
olmutil.free();
}
return isValid;
}
bool _uploadKeysLock = false;
/// Generates new one time keys, signs everything and upload it to the server.
Future uploadKeys({
bool uploadDeviceKeys = false,
int? oldKeyCount = 0,
bool updateDatabase = true,
bool? unusedFallbackKey = false,
}) async {
final _olmAccount = this._olmAccount;
if (_olmAccount == null) {
return true;
}
if (_uploadKeysLock) {
return false;
}
_uploadKeysLock = true;
try {
final signedOneTimeKeys = {};
int? uploadedOneTimeKeysCount;
if (oldKeyCount != null) {
// check if we have OTKs that still need uploading. If we do, we don't try to generate new ones,
// instead we try to upload the old ones first
final oldOTKsNeedingUpload = json
.decode(_olmAccount.one_time_keys())['curve25519']
.entries
.length as int;
// generate one-time keys
// we generate 2/3rds of max, so that other keys people may still have can
// still be used
final oneTimeKeysCount =
(_olmAccount.max_number_of_one_time_keys() * 2 / 3).floor() -
oldKeyCount -
oldOTKsNeedingUpload;
if (oneTimeKeysCount > 0) {
_olmAccount.generate_one_time_keys(oneTimeKeysCount);
}
uploadedOneTimeKeysCount = oneTimeKeysCount + oldOTKsNeedingUpload;
final Map oneTimeKeys =
json.decode(_olmAccount.one_time_keys());
// now sign all the one-time keys
for (final entry in oneTimeKeys['curve25519'].entries) {
final key = entry.key;
final value = entry.value;
signedOneTimeKeys['signed_curve25519:$key'] = signJson({
'key': value,
});
}
}
final signedFallbackKeys = {};
if (encryption.isMinOlmVersion(3, 2, 0) && unusedFallbackKey == false) {
// we don't have an unused fallback key uploaded....so let's change that!
_olmAccount.generate_fallback_key();
final fallbackKey = json.decode(_olmAccount.fallback_key());
// now sign all the fallback keys
for (final entry in fallbackKey['curve25519'].entries) {
final key = entry.key;
final value = entry.value;
signedFallbackKeys['signed_curve25519:$key'] = signJson({
'key': value,
'fallback': true,
});
}
}
// and now generate the payload to upload
final keysContent = {
if (uploadDeviceKeys)
'device_keys': {
'user_id': client.userID,
'device_id': client.deviceID,
'algorithms': [
AlgorithmTypes.olmV1Curve25519AesSha2,
AlgorithmTypes.megolmV1AesSha2
],
'keys': {},
},
};
if (uploadDeviceKeys) {
final Map keys =
json.decode(_olmAccount.identity_keys());
for (final entry in keys.entries) {
final algorithm = entry.key;
final value = entry.value;
keysContent['device_keys']['keys']['$algorithm:${client.deviceID}'] =
value;
}
keysContent['device_keys'] =
signJson(keysContent['device_keys'] as Map);
}
// we save the generated OTKs into the database.
// in case the app gets killed during upload or the upload fails due to bad network
// we can still re-try later
if (updateDatabase) {
await client.database?.updateClientKeys(pickledOlmAccount!);
}
// Workaround: Make sure we stop if we got logged out in the meantime.
if (!client.isLogged()) return true;
final response = await client.uploadKeys(
deviceKeys: uploadDeviceKeys
? MatrixDeviceKeys.fromJson(keysContent['device_keys'])
: null,
oneTimeKeys: signedOneTimeKeys,
fallbackKeys: signedFallbackKeys,
);
// mark the OTKs as published and save that to datbase
_olmAccount.mark_keys_as_published();
if (updateDatabase) {
await client.database?.updateClientKeys(pickledOlmAccount!);
}
return (uploadedOneTimeKeysCount != null &&
response['signed_curve25519'] == uploadedOneTimeKeysCount) ||
uploadedOneTimeKeysCount == null;
} finally {
_uploadKeysLock = false;
}
}
void handleDeviceOneTimeKeysCount(
Map? countJson, List? unusedFallbackKeyTypes) {
if (!enabled) {
return;
}
final haveFallbackKeys = encryption.isMinOlmVersion(3, 2, 0);
// 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 the server did not send us a count, assume it is 0
final keyCount = countJson?.tryGet('signed_curve25519') ?? 0;
// If the server does not support fallback keys, it will not tell us about them.
// If the server supports them but has no key, upload a new one.
var unusedFallbackKey = true;
if (unusedFallbackKeyTypes?.contains('signed_curve25519') == false) {
unusedFallbackKey = false;
}
// fixup accidental too many uploads. We delete only one of them so that the server has time to update the counts and because we will get rate limited anyway.
if (keyCount > _olmAccount!.max_number_of_one_time_keys()) {
final requestingKeysFrom = {
client.userID!: {client.deviceID!: 'signed_curve25519'}
};
client.claimKeys(requestingKeysFrom, timeout: 10000);
}
// Only upload keys if they are less than half of the max or we have no unused fallback key
if (keyCount < (_olmAccount!.max_number_of_one_time_keys() / 2) ||
!unusedFallbackKey) {
uploadKeys(
oldKeyCount: keyCount < (_olmAccount!.max_number_of_one_time_keys() / 2)
? keyCount
: null,
unusedFallbackKey: haveFallbackKeys ? unusedFallbackKey : null,
);
}
}
Future storeOlmSession(OlmSession session) async {
if (session.sessionId == null || session.pickledSession == null) {
return;
}
_olmSessions[session.identityKey] ??= [];
final ix = _olmSessions[session.identityKey]!
.indexWhere((s) => s.sessionId == session.sessionId);
if (ix == -1) {
// add a new session
_olmSessions[session.identityKey]!.add(session);
} else {
// update an existing session
_olmSessions[session.identityKey]![ix] = session;
}
await client.database?.storeOlmSession(
session.identityKey,
session.sessionId!,
session.pickledSession!,
session.lastReceived?.millisecondsSinceEpoch ??
DateTime.now().millisecondsSinceEpoch);
}
ToDeviceEvent _decryptToDeviceEvent(ToDeviceEvent event) {
if (event.type != EventTypes.Encrypted) {
return event;
}
final content = event.parsedRoomEncryptedContent;
if (content.algorithm != AlgorithmTypes.olmV1Curve25519AesSha2) {
throw DecryptException(DecryptException.unknownAlgorithm);
}
if (content.ciphertextOlm == null ||
!content.ciphertextOlm!.containsKey(identityKey)) {
throw DecryptException(DecryptException.isntSentForThisDevice);
}
String? plaintext;
final senderKey = content.senderKey;
final body = content.ciphertextOlm![identityKey]!.body;
final type = content.ciphertextOlm![identityKey]!.type;
if (type != 0 && type != 1) {
throw DecryptException(DecryptException.unknownMessageType);
}
final device = client.userDeviceKeys[event.sender]?.deviceKeys.values
.firstWhereOrNull((d) => d.curve25519Key == senderKey);
final existingSessions = olmSessions[senderKey];
final updateSessionUsage = ([OlmSession? session]) => runInRoot(() async {
if (session != null) {
session.lastReceived = DateTime.now();
await storeOlmSession(session);
}
if (device != null) {
device.lastActive = DateTime.now();
await client.database?.setLastActiveUserDeviceKey(
device.lastActive.millisecondsSinceEpoch,
device.userId,
device.deviceId!);
}
});
if (existingSessions != null) {
for (final session in existingSessions) {
if (session.session == null) {
continue;
}
if (type == 0 && session.session!.matches_inbound(body)) {
try {
plaintext = session.session!.decrypt(type, body);
} catch (e) {
// The message was encrypted during this session, but is unable to decrypt
throw DecryptException(
DecryptException.decryptionFailed, e.toString());
}
updateSessionUsage(session);
break;
} else if (type == 1) {
try {
plaintext = session.session!.decrypt(type, body);
updateSessionUsage(session);
break;
} catch (_) {
plaintext = null;
}
}
}
}
if (plaintext == null && type != 0) {
throw DecryptException(DecryptException.unableToDecryptWithAnyOlmSession);
}
if (plaintext == null) {
final newSession = olm.Session();
try {
newSession.create_inbound_from(_olmAccount!, senderKey, body);
_olmAccount!.remove_one_time_keys(newSession);
client.database?.updateClientKeys(pickledOlmAccount!);
plaintext = newSession.decrypt(type, body);
runInRoot(() => storeOlmSession(OlmSession(
key: client.userID!,
identityKey: senderKey,
sessionId: newSession.session_id(),
session: newSession,
lastReceived: DateTime.now(),
)));
updateSessionUsage();
} catch (e) {
newSession.free();
throw DecryptException(DecryptException.decryptionFailed, e.toString());
}
}
final Map plainContent = json.decode(plaintext);
if (plainContent['sender'] != event.sender) {
throw DecryptException(DecryptException.senderDoesntMatch);
}
if (plainContent['recipient'] != client.userID) {
throw DecryptException(DecryptException.recipientDoesntMatch);
}
if (plainContent['recipient_keys'] is Map &&
plainContent['recipient_keys']['ed25519'] is String &&
plainContent['recipient_keys']['ed25519'] != fingerprintKey) {
throw DecryptException(DecryptException.ownFingerprintDoesntMatch);
}
return ToDeviceEvent(
content: plainContent['content'],
encryptedContent: event.content,
type: plainContent['type'],
sender: event.sender,
);
}
Future> getOlmSessionsFromDatabase(String senderKey) async {
final olmSessions =
await client.database?.getOlmSessions(senderKey, client.userID!);
return olmSessions?.where((sess) => sess.isValid).toList() ?? [];
}
Future getOlmSessionsForDevicesFromDatabase(
List senderKeys) async {
final rows = await client.database?.getOlmSessionsForDevices(
senderKeys,
client.userID!,
);
final res = >{};
for (final sess in rows ?? []) {
res[sess.identityKey] ??= [];
if (sess.isValid) {
res[sess.identityKey]!.add(sess);
}
}
for (final entry in res.entries) {
_olmSessions[entry.key] = entry.value;
}
}
Future> getOlmSessions(String senderKey,
{bool getFromDb = true}) async {
var sess = olmSessions[senderKey];
if ((getFromDb) && (sess == null || sess.isEmpty)) {
final sessions = await getOlmSessionsFromDatabase(senderKey);
if (sessions.isEmpty) {
return [];
}
sess = _olmSessions[senderKey] = sessions;
}
if (sess == null) {
return [];
}
sess.sort((a, b) => a.lastReceived == b.lastReceived
? (a.sessionId ?? '').compareTo(b.sessionId ?? '')
: (b.lastReceived ?? DateTime(0))
.compareTo(a.lastReceived ?? DateTime(0)));
return sess;
}
final Map _restoredOlmSessionsTime = {};
Future restoreOlmSession(String userId, String senderKey) async {
if (!client.userDeviceKeys.containsKey(userId)) {
return;
}
final device = client.userDeviceKeys[userId]!.deviceKeys.values
.firstWhereOrNull((d) => d.curve25519Key == senderKey);
if (device == null) {
return;
}
// per device only one olm session per hour should be restored
final mapKey = '$userId;$senderKey';
if (_restoredOlmSessionsTime.containsKey(mapKey) &&
DateTime.now()
.subtract(Duration(hours: 1))
.isBefore(_restoredOlmSessionsTime[mapKey]!)) {
return;
}
_restoredOlmSessionsTime[mapKey] = DateTime.now();
await startOutgoingOlmSessions([device]);
await client.sendToDeviceEncrypted([device], EventTypes.Dummy, {});
}
Future decryptToDeviceEvent(ToDeviceEvent event) async {
if (event.type != EventTypes.Encrypted) {
return event;
}
final senderKey = event.parsedRoomEncryptedContent.senderKey;
final loadFromDb = () async {
final sessions = await getOlmSessions(senderKey);
return sessions.isNotEmpty;
};
if (!_olmSessions.containsKey(senderKey)) {
await loadFromDb();
}
try {
event = _decryptToDeviceEvent(event);
if (event.type != EventTypes.Encrypted || !(await loadFromDb())) {
return event;
}
// retry to decrypt!
return _decryptToDeviceEvent(event);
} catch (_) {
// okay, the thing errored while decrypting. It is safe to assume that the olm session is corrupt and we should generate a new one
// ignore: unawaited_futures
runInRoot(() => restoreOlmSession(event.senderId, senderKey));
rethrow;
}
}
Future startOutgoingOlmSessions(List deviceKeys) async {
Logs().v(
'[OlmManager] Starting session with ${deviceKeys.length} devices...');
final requestingKeysFrom = >{};
for (final device in deviceKeys) {
if (requestingKeysFrom[device.userId] == null) {
requestingKeysFrom[device.userId] = {};
}
requestingKeysFrom[device.userId]![device.deviceId!] =
'signed_curve25519';
}
final response = await client.claimKeys(requestingKeysFrom, timeout: 10000);
for (final userKeysEntry in response.oneTimeKeys.entries) {
final userId = userKeysEntry.key;
for (final deviceKeysEntry in userKeysEntry.value.entries) {
final deviceId = deviceKeysEntry.key;
final fingerprintKey =
client.userDeviceKeys[userId]!.deviceKeys[deviceId]!.ed25519Key;
final identityKey =
client.userDeviceKeys[userId]!.deviceKeys[deviceId]!.curve25519Key;
for (final Map deviceKey
in deviceKeysEntry.value.values) {
if (fingerprintKey == null ||
identityKey == null ||
!deviceKey.checkJsonSignature(fingerprintKey, userId, deviceId)) {
continue;
}
Logs().v('[OlmManager] Starting session with $userId:$deviceId');
final session = olm.Session();
try {
session.create_outbound(
_olmAccount!, identityKey, deviceKey['key']);
await storeOlmSession(OlmSession(
key: client.userID!,
identityKey: identityKey,
sessionId: session.session_id(),
session: session,
lastReceived:
DateTime.now(), // we want to use a newly created session
));
} catch (e, s) {
session.free();
Logs()
.e('[LibOlm] Could not create new outbound olm session', e, s);
}
}
}
}
}
Future