Initial commit version 0.8.13
This commit is contained in:
commit
9526dfa4f2
111 changed files with 35074 additions and 0 deletions
26
lib/encryption.dart
Normal file
26
lib/encryption.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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/>.
|
||||
*/
|
||||
|
||||
/// Matrix SDK encryption specific extension
|
||||
library encryption;
|
||||
|
||||
export 'encryption/encryption.dart';
|
||||
export 'encryption/key_manager.dart';
|
||||
export 'encryption/ssss.dart';
|
||||
export 'encryption/utils/key_verification.dart';
|
||||
export 'encryption/utils/bootstrap.dart';
|
||||
210
lib/encryption/cross_signing.dart
Normal file
210
lib/encryption/cross_signing.dart
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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:typed_data';
|
||||
|
||||
import 'package:matrix/encryption/utils/base64_unpadded.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../matrix.dart';
|
||||
import 'encryption.dart';
|
||||
import 'ssss.dart';
|
||||
|
||||
class CrossSigning {
|
||||
final Encryption encryption;
|
||||
Client get client => encryption.client;
|
||||
CrossSigning(this.encryption) {
|
||||
encryption.ssss.setValidator(EventTypes.CrossSigningSelfSigning,
|
||||
(String secret) async {
|
||||
final keyObj = olm.PkSigning();
|
||||
try {
|
||||
return keyObj.init_with_seed(base64decodeUnpadded(secret)) ==
|
||||
client.userDeviceKeys[client.userID]!.selfSigningKey!.ed25519Key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
});
|
||||
encryption.ssss.setValidator(EventTypes.CrossSigningUserSigning,
|
||||
(String secret) async {
|
||||
final keyObj = olm.PkSigning();
|
||||
try {
|
||||
return keyObj.init_with_seed(base64decodeUnpadded(secret)) ==
|
||||
client.userDeviceKeys[client.userID]!.userSigningKey!.ed25519Key;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool get enabled =>
|
||||
encryption.ssss.isSecret(EventTypes.CrossSigningSelfSigning) &&
|
||||
encryption.ssss.isSecret(EventTypes.CrossSigningUserSigning) &&
|
||||
encryption.ssss.isSecret(EventTypes.CrossSigningMasterKey);
|
||||
|
||||
Future<bool> isCached() async {
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
return (await encryption.ssss
|
||||
.getCached(EventTypes.CrossSigningSelfSigning)) !=
|
||||
null &&
|
||||
(await encryption.ssss.getCached(EventTypes.CrossSigningUserSigning)) !=
|
||||
null;
|
||||
}
|
||||
|
||||
Future<void> selfSign(
|
||||
{String? passphrase,
|
||||
String? recoveryKey,
|
||||
String? keyOrPassphrase,
|
||||
OpenSSSS? openSsss}) async {
|
||||
var handle = openSsss;
|
||||
if (handle == null) {
|
||||
handle = encryption.ssss.open(EventTypes.CrossSigningMasterKey);
|
||||
await handle.unlock(
|
||||
passphrase: passphrase,
|
||||
recoveryKey: recoveryKey,
|
||||
keyOrPassphrase: keyOrPassphrase,
|
||||
postUnlock: false,
|
||||
);
|
||||
await handle.maybeCacheAll();
|
||||
}
|
||||
final masterPrivateKey = base64decodeUnpadded(
|
||||
await handle.getStored(EventTypes.CrossSigningMasterKey));
|
||||
final keyObj = olm.PkSigning();
|
||||
String? masterPubkey;
|
||||
try {
|
||||
masterPubkey = keyObj.init_with_seed(masterPrivateKey);
|
||||
} catch (e) {
|
||||
masterPubkey = null;
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
final userDeviceKeys =
|
||||
client.userDeviceKeys[client.userID]?.deviceKeys[client.deviceID];
|
||||
if (masterPubkey == null || userDeviceKeys == null) {
|
||||
throw Exception('Master or user keys not found');
|
||||
}
|
||||
final masterKey = client.userDeviceKeys[client.userID]?.masterKey;
|
||||
if (masterKey == null || masterKey.ed25519Key != masterPubkey) {
|
||||
throw Exception('Master pubkey key doesn\'t match');
|
||||
}
|
||||
// master key is valid, set it to verified
|
||||
await masterKey.setVerified(true, false);
|
||||
// and now sign both our own key and our master key
|
||||
await sign([
|
||||
masterKey,
|
||||
userDeviceKeys,
|
||||
]);
|
||||
}
|
||||
|
||||
bool signable(List<SignableKey> keys) => keys.any((key) =>
|
||||
key is CrossSigningKey && key.usage.contains('master') ||
|
||||
key is DeviceKeys &&
|
||||
key.userId == client.userID &&
|
||||
key.identifier != client.deviceID);
|
||||
|
||||
Future<void> sign(List<SignableKey> keys) async {
|
||||
final signedKeys = <MatrixSignableKey>[];
|
||||
Uint8List? selfSigningKey;
|
||||
Uint8List? userSigningKey;
|
||||
final userKeys = client.userDeviceKeys[client.userID];
|
||||
if (userKeys == null) {
|
||||
throw Exception('[sign] keys are not in cache but sign was called');
|
||||
}
|
||||
|
||||
final addSignature =
|
||||
(SignableKey key, SignableKey signedWith, String signature) {
|
||||
final signedKey = key.cloneForSigning();
|
||||
((signedKey.signatures ??=
|
||||
<String, Map<String, String>>{})[signedWith.userId] ??=
|
||||
<String, String>{})['ed25519:${signedWith.identifier}'] = signature;
|
||||
signedKeys.add(signedKey);
|
||||
};
|
||||
|
||||
for (final key in keys) {
|
||||
if (key.userId == client.userID) {
|
||||
// we are singing a key of ourself
|
||||
if (key is CrossSigningKey) {
|
||||
if (key.usage.contains('master')) {
|
||||
// okay, we'll sign our own master key
|
||||
final signature =
|
||||
encryption.olmManager.signString(key.signingContent);
|
||||
addSignature(key, userKeys.deviceKeys[client.deviceID]!, signature);
|
||||
}
|
||||
// we don't care about signing other cross-signing keys
|
||||
} else {
|
||||
// okay, we'll sign a device key with our self signing key
|
||||
selfSigningKey ??= base64decodeUnpadded(await encryption.ssss
|
||||
.getCached(EventTypes.CrossSigningSelfSigning) ??
|
||||
'');
|
||||
if (selfSigningKey.isNotEmpty) {
|
||||
final signature = _sign(key.signingContent, selfSigningKey);
|
||||
addSignature(key, userKeys.selfSigningKey!, signature);
|
||||
}
|
||||
}
|
||||
} else if (key is CrossSigningKey && key.usage.contains('master')) {
|
||||
// we are signing someone elses master key
|
||||
userSigningKey ??= base64decodeUnpadded(await encryption.ssss
|
||||
.getCached(EventTypes.CrossSigningUserSigning) ??
|
||||
'');
|
||||
if (userSigningKey.isNotEmpty) {
|
||||
final signature = _sign(key.signingContent, userSigningKey);
|
||||
addSignature(key, userKeys.userSigningKey!, signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (signedKeys.isNotEmpty) {
|
||||
// post our new keys!
|
||||
final payload = <String, Map<String, Map<String, dynamic>>>{};
|
||||
for (final key in signedKeys) {
|
||||
if (key.identifier == null ||
|
||||
key.signatures == null ||
|
||||
key.signatures?.isEmpty != false) {
|
||||
continue;
|
||||
}
|
||||
if (!payload.containsKey(key.userId)) {
|
||||
payload[key.userId] = <String, Map<String, dynamic>>{};
|
||||
}
|
||||
if (payload[key.userId]?[key.identifier]?['signatures'] != null) {
|
||||
// we need to merge signature objects
|
||||
payload[key.userId]![key.identifier]!['signatures']
|
||||
.addAll(key.signatures);
|
||||
} else {
|
||||
// we can just add signatures
|
||||
payload[key.userId]![key.identifier!] = key.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
await client.uploadCrossSigningSignatures(payload);
|
||||
}
|
||||
}
|
||||
|
||||
String _sign(String canonicalJson, Uint8List key) {
|
||||
final keyObj = olm.PkSigning();
|
||||
try {
|
||||
keyObj.init_with_seed(key);
|
||||
return keyObj.sign(canonicalJson);
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
455
lib/encryption/encryption.dart
Normal file
455
lib/encryption/encryption.dart
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
/*
|
||||
* 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 'dart:async';
|
||||
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../matrix.dart';
|
||||
import '../src/utils/run_in_root.dart';
|
||||
import 'cross_signing.dart';
|
||||
import 'key_manager.dart';
|
||||
import 'key_verification_manager.dart';
|
||||
import 'olm_manager.dart';
|
||||
import 'ssss.dart';
|
||||
import 'utils/bootstrap.dart';
|
||||
|
||||
class Encryption {
|
||||
final Client client;
|
||||
final bool debug;
|
||||
|
||||
bool get enabled => olmManager.enabled;
|
||||
|
||||
/// Returns the base64 encoded keys to store them in a store.
|
||||
/// This String should **never** leave the device!
|
||||
String? get pickledOlmAccount => olmManager.pickledOlmAccount;
|
||||
|
||||
String? get fingerprintKey => olmManager.fingerprintKey;
|
||||
String? get identityKey => olmManager.identityKey;
|
||||
|
||||
late KeyManager keyManager;
|
||||
late OlmManager olmManager;
|
||||
late KeyVerificationManager keyVerificationManager;
|
||||
late CrossSigning crossSigning;
|
||||
late SSSS ssss;
|
||||
|
||||
Encryption({
|
||||
required this.client,
|
||||
this.debug = false,
|
||||
}) {
|
||||
ssss = SSSS(this);
|
||||
keyManager = KeyManager(this);
|
||||
olmManager = OlmManager(this);
|
||||
keyVerificationManager = KeyVerificationManager(this);
|
||||
crossSigning = CrossSigning(this);
|
||||
}
|
||||
|
||||
// initial login passes null to init a new olm account
|
||||
Future<void> init(String? olmAccount) async {
|
||||
await olmManager.init(olmAccount);
|
||||
_backgroundTasksRunning = true;
|
||||
_backgroundTasks(); // start the background tasks
|
||||
}
|
||||
|
||||
bool isMinOlmVersion(int major, int minor, int patch) {
|
||||
try {
|
||||
final version = olm.get_library_version();
|
||||
return version[0] > major ||
|
||||
(version[0] == major &&
|
||||
(version[1] > minor ||
|
||||
(version[1] == minor && version[2] >= patch)));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Bootstrap bootstrap({void Function()? onUpdate}) => Bootstrap(
|
||||
encryption: this,
|
||||
onUpdate: onUpdate,
|
||||
);
|
||||
|
||||
void handleDeviceOneTimeKeysCount(
|
||||
Map<String, int>? countJson, List<String>? unusedFallbackKeyTypes) {
|
||||
runInRoot(() => olmManager.handleDeviceOneTimeKeysCount(
|
||||
countJson, unusedFallbackKeyTypes));
|
||||
}
|
||||
|
||||
void onSync() {
|
||||
keyVerificationManager.cleanup();
|
||||
}
|
||||
|
||||
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
||||
if (event.type == EventTypes.RoomKey) {
|
||||
// a new room key. We need to handle this asap, before other
|
||||
// events in /sync are handled
|
||||
await keyManager.handleToDeviceEvent(event);
|
||||
}
|
||||
if ([EventTypes.RoomKeyRequest, EventTypes.ForwardedRoomKey]
|
||||
.contains(event.type)) {
|
||||
// "just" room key request things. We don't need these asap, so we handle
|
||||
// them in the background
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => keyManager.handleToDeviceEvent(event));
|
||||
}
|
||||
if (event.type == EventTypes.Dummy) {
|
||||
// the previous device just had to create a new olm session, due to olm session
|
||||
// corruption. We want to try to send it the last message we just sent it, if possible
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => olmManager.handleToDeviceEvent(event));
|
||||
}
|
||||
if (event.type.startsWith('m.key.verification.')) {
|
||||
// some key verification event. No need to handle it now, we can easily
|
||||
// do this in the background
|
||||
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => keyVerificationManager.handleToDeviceEvent(event));
|
||||
}
|
||||
if (event.type.startsWith('m.secret.')) {
|
||||
// some ssss thing. We can do this in the background
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => ssss.handleToDeviceEvent(event));
|
||||
}
|
||||
if (event.sender == client.userID) {
|
||||
// maybe we need to re-try SSSS secrets
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => ssss.periodicallyRequestMissingCache());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleEventUpdate(EventUpdate update) async {
|
||||
if (update.type == EventUpdateType.ephemeral ||
|
||||
update.type == EventUpdateType.history) {
|
||||
return;
|
||||
}
|
||||
if (update.content['type'].startsWith('m.key.verification.') ||
|
||||
(update.content['type'] == EventTypes.Message &&
|
||||
(update.content['content']['msgtype'] is String) &&
|
||||
update.content['content']['msgtype']
|
||||
.startsWith('m.key.verification.'))) {
|
||||
// "just" key verification, no need to do this in sync
|
||||
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => keyVerificationManager.handleEventUpdate(update));
|
||||
}
|
||||
if (update.content['sender'] == client.userID &&
|
||||
update.content['unsigned']?['transaction_id'] == null) {
|
||||
// maybe we need to re-try SSSS secrets
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => ssss.periodicallyRequestMissingCache());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ToDeviceEvent> decryptToDeviceEvent(ToDeviceEvent event) async {
|
||||
try {
|
||||
return await olmManager.decryptToDeviceEvent(event);
|
||||
} catch (e, s) {
|
||||
Logs().w(
|
||||
'[LibOlm] Could not decrypt to device event from ${event.sender} with content: ${event.content}',
|
||||
e,
|
||||
s);
|
||||
client.onEncryptionError.add(
|
||||
SdkError(
|
||||
exception: e is Exception ? e : Exception(e),
|
||||
stackTrace: s,
|
||||
),
|
||||
);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
Event decryptRoomEventSync(String roomId, Event event) {
|
||||
final content = event.parsedRoomEncryptedContent;
|
||||
if (event.type != EventTypes.Encrypted ||
|
||||
content.ciphertextMegolm == null) {
|
||||
return event;
|
||||
}
|
||||
Map<String, dynamic> decryptedPayload;
|
||||
var canRequestSession = false;
|
||||
try {
|
||||
if (content.algorithm != AlgorithmTypes.megolmV1AesSha2) {
|
||||
throw DecryptException(DecryptException.unknownAlgorithm);
|
||||
}
|
||||
final sessionId = content.sessionId;
|
||||
final senderKey = content.senderKey;
|
||||
if (sessionId == null) {
|
||||
throw DecryptException(DecryptException.unknownSession);
|
||||
}
|
||||
|
||||
final inboundGroupSession =
|
||||
keyManager.getInboundGroupSession(roomId, sessionId, senderKey);
|
||||
if (!(inboundGroupSession?.isValid ?? false)) {
|
||||
canRequestSession = true;
|
||||
throw DecryptException(DecryptException.unknownSession);
|
||||
}
|
||||
|
||||
// decrypt errors here may mean we have a bad session key - others might have a better one
|
||||
canRequestSession = true;
|
||||
|
||||
final decryptResult = inboundGroupSession!.inboundGroupSession!
|
||||
.decrypt(content.ciphertextMegolm!);
|
||||
canRequestSession = false;
|
||||
|
||||
// we can't have the key be an int, else json-serializing will fail, thus we need it to be a string
|
||||
final messageIndexKey = 'key-' + decryptResult.message_index.toString();
|
||||
final messageIndexValue = event.eventId +
|
||||
'|' +
|
||||
event.originServerTs.millisecondsSinceEpoch.toString();
|
||||
final haveIndex =
|
||||
inboundGroupSession.indexes.containsKey(messageIndexKey);
|
||||
if (haveIndex &&
|
||||
inboundGroupSession.indexes[messageIndexKey] != messageIndexValue) {
|
||||
Logs().e('[Decrypt] Could not decrypt due to a corrupted session.');
|
||||
throw DecryptException(DecryptException.channelCorrupted);
|
||||
}
|
||||
|
||||
inboundGroupSession.indexes[messageIndexKey] = messageIndexValue;
|
||||
if (!haveIndex) {
|
||||
// now we persist the udpated indexes into the database.
|
||||
// the entry should always exist. In the case it doesn't, the following
|
||||
// line *could* throw an error. As that is a future, though, and we call
|
||||
// it un-awaited here, nothing happens, which is exactly the result we want
|
||||
client.database?.updateInboundGroupSessionIndexes(
|
||||
json.encode(inboundGroupSession.indexes), roomId, sessionId);
|
||||
}
|
||||
decryptedPayload = json.decode(decryptResult.plaintext);
|
||||
} catch (exception) {
|
||||
// alright, if this was actually by our own outbound group session, we might as well clear it
|
||||
if (exception.toString() != DecryptException.unknownSession &&
|
||||
(keyManager
|
||||
.getOutboundGroupSession(roomId)
|
||||
?.outboundGroupSession
|
||||
?.session_id() ??
|
||||
'') ==
|
||||
content.sessionId) {
|
||||
runInRoot(() =>
|
||||
keyManager.clearOrUseOutboundGroupSession(roomId, wipe: true));
|
||||
}
|
||||
if (canRequestSession) {
|
||||
decryptedPayload = {
|
||||
'content': event.content,
|
||||
'type': EventTypes.Encrypted,
|
||||
};
|
||||
decryptedPayload['content']['body'] = exception.toString();
|
||||
decryptedPayload['content']['msgtype'] = MessageTypes.BadEncrypted;
|
||||
decryptedPayload['content']['can_request_session'] = true;
|
||||
} else {
|
||||
decryptedPayload = {
|
||||
'content': <String, dynamic>{
|
||||
'msgtype': MessageTypes.BadEncrypted,
|
||||
'body': exception.toString(),
|
||||
},
|
||||
'type': EventTypes.Encrypted,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (event.content['m.relates_to'] != null) {
|
||||
decryptedPayload['content']['m.relates_to'] =
|
||||
event.content['m.relates_to'];
|
||||
}
|
||||
return Event(
|
||||
content: decryptedPayload['content'],
|
||||
type: decryptedPayload['type'],
|
||||
senderId: event.senderId,
|
||||
eventId: event.eventId,
|
||||
room: event.room,
|
||||
originServerTs: event.originServerTs,
|
||||
unsigned: event.unsigned,
|
||||
stateKey: event.stateKey,
|
||||
prevContent: event.prevContent,
|
||||
status: event.status,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Event> decryptRoomEvent(String roomId, Event event,
|
||||
{bool store = false,
|
||||
EventUpdateType updateType = EventUpdateType.timeline}) async {
|
||||
if (event.type != EventTypes.Encrypted) {
|
||||
return event;
|
||||
}
|
||||
final content = event.parsedRoomEncryptedContent;
|
||||
final sessionId = content.sessionId;
|
||||
try {
|
||||
if (client.database != null &&
|
||||
sessionId != null &&
|
||||
!(keyManager
|
||||
.getInboundGroupSession(
|
||||
roomId,
|
||||
sessionId,
|
||||
content.senderKey,
|
||||
)
|
||||
?.isValid ??
|
||||
false)) {
|
||||
await keyManager.loadInboundGroupSession(
|
||||
roomId,
|
||||
sessionId,
|
||||
content.senderKey,
|
||||
);
|
||||
}
|
||||
event = decryptRoomEventSync(roomId, event);
|
||||
if (event.type == EventTypes.Encrypted &&
|
||||
event.content['can_request_session'] == true &&
|
||||
sessionId != null) {
|
||||
keyManager.maybeAutoRequest(
|
||||
roomId,
|
||||
sessionId,
|
||||
content.senderKey,
|
||||
);
|
||||
}
|
||||
if (event.type != EventTypes.Encrypted && store) {
|
||||
if (updateType != EventUpdateType.history) {
|
||||
event.room.setState(event);
|
||||
}
|
||||
await client.database?.storeEventUpdate(
|
||||
EventUpdate(
|
||||
content: event.toJson(),
|
||||
roomID: roomId,
|
||||
type: updateType,
|
||||
),
|
||||
client,
|
||||
);
|
||||
}
|
||||
return event;
|
||||
} catch (e, s) {
|
||||
Logs().e('[Decrypt] Could not decrpyt event', e, s);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
String roomId, Map<String, dynamic> payload,
|
||||
{String type = EventTypes.Message}) async {
|
||||
final Map<String, dynamic>? mRelatesTo = payload.remove('m.relates_to');
|
||||
// Events which only contain a m.relates_to like reactions don't need to
|
||||
// be encrypted.
|
||||
if (payload.isEmpty && mRelatesTo != null) {
|
||||
return {'m.relates_to': mRelatesTo};
|
||||
}
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room == null || !room.encrypted || !enabled) {
|
||||
return payload;
|
||||
}
|
||||
if (room.encryptionAlgorithm != AlgorithmTypes.megolmV1AesSha2) {
|
||||
throw ('Unknown encryption algorithm');
|
||||
}
|
||||
if (keyManager.getOutboundGroupSession(roomId)?.isValid != true) {
|
||||
await keyManager.loadOutboundGroupSession(roomId);
|
||||
}
|
||||
await keyManager.clearOrUseOutboundGroupSession(roomId);
|
||||
if (keyManager.getOutboundGroupSession(roomId)?.isValid != true) {
|
||||
await keyManager.createOutboundGroupSession(roomId);
|
||||
}
|
||||
final sess = keyManager.getOutboundGroupSession(roomId);
|
||||
if (sess?.isValid != true) {
|
||||
throw ('Unable to create new outbound group session');
|
||||
}
|
||||
// we clone the payload as we do not want to remove 'm.relates_to' from the
|
||||
// original payload passed into this function
|
||||
payload = payload.copy();
|
||||
final payloadContent = {
|
||||
'content': payload,
|
||||
'type': type,
|
||||
'room_id': roomId,
|
||||
};
|
||||
final encryptedPayload = <String, dynamic>{
|
||||
'algorithm': AlgorithmTypes.megolmV1AesSha2,
|
||||
'ciphertext':
|
||||
sess!.outboundGroupSession!.encrypt(json.encode(payloadContent)),
|
||||
'device_id': client.deviceID,
|
||||
'sender_key': identityKey,
|
||||
'session_id': sess.outboundGroupSession!.session_id(),
|
||||
if (mRelatesTo != null) 'm.relates_to': mRelatesTo,
|
||||
};
|
||||
await keyManager.storeOutboundGroupSession(roomId, sess);
|
||||
return encryptedPayload;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> encryptToDeviceMessage(
|
||||
List<DeviceKeys> deviceKeys,
|
||||
String type,
|
||||
Map<String, dynamic> payload) async {
|
||||
return await olmManager.encryptToDeviceMessage(deviceKeys, type, payload);
|
||||
}
|
||||
|
||||
Future<void> autovalidateMasterOwnKey() async {
|
||||
// check if we can set our own master key as verified, if it isn't yet
|
||||
final userId = client.userID;
|
||||
final masterKey = client.userDeviceKeys[userId]?.masterKey;
|
||||
if (client.database != null &&
|
||||
masterKey != null &&
|
||||
userId != null &&
|
||||
!masterKey.directVerified &&
|
||||
masterKey.hasValidSignatureChain(onlyValidateUserIds: {userId})) {
|
||||
await masterKey.setVerified(true);
|
||||
}
|
||||
}
|
||||
|
||||
// this method is responsible for all background tasks, such as uploading online key backups
|
||||
bool _backgroundTasksRunning = true;
|
||||
void _backgroundTasks() {
|
||||
if (!_backgroundTasksRunning || !client.isLogged()) {
|
||||
return;
|
||||
}
|
||||
|
||||
keyManager.backgroundTasks();
|
||||
|
||||
// autovalidateMasterOwnKey();
|
||||
|
||||
if (_backgroundTasksRunning) {
|
||||
Timer(Duration(seconds: 10), _backgroundTasks);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_backgroundTasksRunning = false;
|
||||
keyManager.dispose();
|
||||
olmManager.dispose();
|
||||
keyVerificationManager.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class DecryptException implements Exception {
|
||||
String cause;
|
||||
String? libolmMessage;
|
||||
DecryptException(this.cause, [this.libolmMessage]);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
cause + (libolmMessage != null ? ': $libolmMessage' : '');
|
||||
|
||||
static const String notEnabled = 'Encryption is not enabled in your client.';
|
||||
static const String unknownAlgorithm = 'Unknown encryption algorithm.';
|
||||
static const String unknownSession =
|
||||
'The sender has not sent us the session key.';
|
||||
static const String channelCorrupted =
|
||||
'The secure channel with the sender was corrupted.';
|
||||
static const String unableToDecryptWithAnyOlmSession =
|
||||
'Unable to decrypt with any existing OLM session';
|
||||
static const String senderDoesntMatch =
|
||||
"Message was decrypted but sender doesn't match";
|
||||
static const String recipientDoesntMatch =
|
||||
"Message was decrypted but recipient doesn't match";
|
||||
static const String ownFingerprintDoesntMatch =
|
||||
"Message was decrypted but own fingerprint Key doesn't match";
|
||||
static const String isntSentForThisDevice =
|
||||
"The message isn't sent for this device";
|
||||
static const String unknownMessageType = 'Unknown message type';
|
||||
static const String decryptionFailed = 'Decryption failed';
|
||||
}
|
||||
1093
lib/encryption/key_manager.dart
Normal file
1093
lib/encryption/key_manager.dart
Normal file
File diff suppressed because it is too large
Load diff
148
lib/encryption/key_verification_manager.dart
Normal file
148
lib/encryption/key_verification_manager.dart
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 '../matrix.dart';
|
||||
import 'encryption.dart';
|
||||
import 'utils/key_verification.dart';
|
||||
|
||||
class KeyVerificationManager {
|
||||
final Encryption encryption;
|
||||
Client get client => encryption.client;
|
||||
|
||||
KeyVerificationManager(this.encryption);
|
||||
|
||||
final Map<String, KeyVerification> _requests = {};
|
||||
|
||||
Future<void> cleanup() async {
|
||||
final Set entriesToDispose = <String>{};
|
||||
for (final entry in _requests.entries) {
|
||||
var dispose = entry.value.canceled ||
|
||||
entry.value.state == KeyVerificationState.done ||
|
||||
entry.value.state == KeyVerificationState.error;
|
||||
if (!dispose) {
|
||||
dispose = !(await entry.value.verifyActivity());
|
||||
}
|
||||
if (dispose) {
|
||||
entry.value.dispose();
|
||||
entriesToDispose.add(entry.key);
|
||||
}
|
||||
}
|
||||
entriesToDispose.forEach(_requests.remove);
|
||||
}
|
||||
|
||||
void addRequest(KeyVerification request) {
|
||||
if (request.transactionId == null) {
|
||||
return;
|
||||
}
|
||||
_requests[request.transactionId!] = request;
|
||||
}
|
||||
|
||||
KeyVerification? getRequest(String requestId) => _requests[requestId];
|
||||
|
||||
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
||||
if (!event.type.startsWith('m.key.verification.') ||
|
||||
client.verificationMethods.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// we have key verification going on!
|
||||
final transactionId = KeyVerification.getTransactionId(event.content);
|
||||
if (transactionId == null) {
|
||||
return; // TODO: send cancel with unknown transaction id
|
||||
}
|
||||
final request = _requests[transactionId];
|
||||
if (request != null) {
|
||||
// make sure that new requests can't come from ourself
|
||||
if (!{EventTypes.KeyVerificationRequest}.contains(event.type)) {
|
||||
await request.handlePayload(event.type, event.content);
|
||||
}
|
||||
} else {
|
||||
if (!{EventTypes.KeyVerificationRequest, EventTypes.KeyVerificationStart}
|
||||
.contains(event.type)) {
|
||||
return; // we can only start on these
|
||||
}
|
||||
final newKeyRequest =
|
||||
KeyVerification(encryption: encryption, userId: event.sender);
|
||||
await newKeyRequest.handlePayload(event.type, event.content);
|
||||
if (newKeyRequest.state != KeyVerificationState.askAccept) {
|
||||
// okay, something went wrong (unknown transaction id?), just dispose it
|
||||
newKeyRequest.dispose();
|
||||
} else {
|
||||
_requests[transactionId] = newKeyRequest;
|
||||
client.onKeyVerificationRequest.add(newKeyRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleEventUpdate(EventUpdate update) async {
|
||||
final event = update.content;
|
||||
final type = event['type'].startsWith('m.key.verification.')
|
||||
? event['type']
|
||||
: event['content']['msgtype'];
|
||||
if (type == null ||
|
||||
!type.startsWith('m.key.verification.') ||
|
||||
client.verificationMethods.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (type == EventTypes.KeyVerificationRequest) {
|
||||
event['content']['timestamp'] = event['origin_server_ts'];
|
||||
}
|
||||
|
||||
final transactionId =
|
||||
KeyVerification.getTransactionId(event['content']) ?? event['event_id'];
|
||||
|
||||
final req = _requests[transactionId];
|
||||
if (req != null) {
|
||||
final otherDeviceId = event['content']['from_device'];
|
||||
if (event['sender'] != client.userID) {
|
||||
await req.handlePayload(type, event['content'], event['event_id']);
|
||||
} else if (event['sender'] == client.userID &&
|
||||
otherDeviceId != null &&
|
||||
otherDeviceId != client.deviceID) {
|
||||
// okay, another of our devices answered
|
||||
req.otherDeviceAccepted();
|
||||
req.dispose();
|
||||
_requests.remove(transactionId);
|
||||
}
|
||||
} else if (event['sender'] != client.userID) {
|
||||
if (!{EventTypes.KeyVerificationRequest, EventTypes.KeyVerificationStart}
|
||||
.contains(type)) {
|
||||
return; // we can only start on these
|
||||
}
|
||||
final room = client.getRoomById(update.roomID) ??
|
||||
Room(id: update.roomID, client: client);
|
||||
final newKeyRequest = KeyVerification(
|
||||
encryption: encryption, userId: event['sender'], room: room);
|
||||
await newKeyRequest.handlePayload(
|
||||
type, event['content'], event['event_id']);
|
||||
if (newKeyRequest.state != KeyVerificationState.askAccept) {
|
||||
// something went wrong, let's just dispose the request
|
||||
newKeyRequest.dispose();
|
||||
} else {
|
||||
// new request! Let's notify it and stuff
|
||||
_requests[transactionId] = newKeyRequest;
|
||||
client.onKeyVerificationRequest.add(newKeyRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final req in _requests.values) {
|
||||
req.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
690
lib/encryption/olm_manager.dart
Normal file
690
lib/encryption/olm_manager.dart
Normal file
|
|
@ -0,0 +1,690 @@
|
|||
/*
|
||||
* 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: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<String, List<OlmSession>> get olmSessions => _olmSessions;
|
||||
final Map<String, List<OlmSession>> _olmSessions = {};
|
||||
|
||||
// NOTE(Nico): On initial login we pass null to create a new account
|
||||
Future<void> 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<String, dynamic> signJson(Map<String, dynamic> payload) {
|
||||
if (!enabled) 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 canonical = canonicalJson.encode(payload);
|
||||
final signature = _olmAccount!.sign(String.fromCharCodes(canonical));
|
||||
if (signatures != null) {
|
||||
payload['signatures'] = signatures;
|
||||
} else {
|
||||
payload['signatures'] = <String, dynamic>{};
|
||||
}
|
||||
if (!payload['signatures'].containsKey(client.userID)) {
|
||||
payload['signatures'][client.userID] = <String, dynamic>{};
|
||||
}
|
||||
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<String, dynamic> signedJson,
|
||||
String userId, String deviceId) {
|
||||
if (!enabled) 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 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<bool> 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 = <String, dynamic>{};
|
||||
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<String, dynamic> 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 = <String, dynamic>{};
|
||||
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 = <String, dynamic>{
|
||||
if (uploadDeviceKeys)
|
||||
'device_keys': {
|
||||
'user_id': client.userID,
|
||||
'device_id': client.deviceID,
|
||||
'algorithms': [
|
||||
AlgorithmTypes.olmV1Curve25519AesSha2,
|
||||
AlgorithmTypes.megolmV1AesSha2
|
||||
],
|
||||
'keys': <String, dynamic>{},
|
||||
},
|
||||
};
|
||||
if (uploadDeviceKeys) {
|
||||
final Map<String, dynamic> 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<String, dynamic>);
|
||||
}
|
||||
|
||||
// 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<String, int>? countJson, List<String>? 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<int>('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<void> storeOlmSession(OlmSession session) async {
|
||||
if (session.sessionId == null || session.pickledSession == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_olmSessions[session.identityKey] ??= <OlmSession>[];
|
||||
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<String, dynamic> 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<List<OlmSession>> getOlmSessionsFromDatabase(String senderKey) async {
|
||||
final olmSessions =
|
||||
await client.database?.getOlmSessions(senderKey, client.userID!);
|
||||
return olmSessions?.where((sess) => sess.isValid).toList() ?? [];
|
||||
}
|
||||
|
||||
Future<void> getOlmSessionsForDevicesFromDatabase(
|
||||
List<String> senderKeys) async {
|
||||
final rows = await client.database?.getOlmSessionsForDevices(
|
||||
senderKeys,
|
||||
client.userID!,
|
||||
);
|
||||
final res = <String, List<OlmSession>>{};
|
||||
for (final sess in rows ?? []) {
|
||||
res[sess.identityKey] ??= <OlmSession>[];
|
||||
if (sess.isValid) {
|
||||
res[sess.identityKey]!.add(sess);
|
||||
}
|
||||
}
|
||||
for (final entry in res.entries) {
|
||||
_olmSessions[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<OlmSession>> 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<String, DateTime> _restoredOlmSessionsTime = {};
|
||||
|
||||
Future<void> 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<ToDeviceEvent> 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<void> startOutgoingOlmSessions(List<DeviceKeys> deviceKeys) async {
|
||||
Logs().v(
|
||||
'[OlmManager] Starting session with ${deviceKeys.length} devices...');
|
||||
final requestingKeysFrom = <String, Map<String, String>>{};
|
||||
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<String, dynamic> 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<Map<String, dynamic>> encryptToDeviceMessagePayload(
|
||||
DeviceKeys device, String type, Map<String, dynamic> payload,
|
||||
{bool getFromDb = true}) async {
|
||||
final sess =
|
||||
await getOlmSessions(device.curve25519Key!, getFromDb: getFromDb);
|
||||
if (sess.isEmpty) {
|
||||
throw ('No olm session found for ${device.userId}:${device.deviceId}');
|
||||
}
|
||||
final fullPayload = {
|
||||
'type': type,
|
||||
'content': payload,
|
||||
'sender': client.userID,
|
||||
'keys': {'ed25519': fingerprintKey},
|
||||
'recipient': device.userId,
|
||||
'recipient_keys': {'ed25519': device.ed25519Key},
|
||||
};
|
||||
final encryptResult = sess.first.session!.encrypt(json.encode(fullPayload));
|
||||
await storeOlmSession(sess.first);
|
||||
if (client.database != null) {
|
||||
// ignore: unawaited_futures
|
||||
runInRoot(() => client.database?.setLastSentMessageUserDeviceKey(
|
||||
json.encode({
|
||||
'type': type,
|
||||
'content': payload,
|
||||
}),
|
||||
device.userId,
|
||||
device.deviceId!));
|
||||
}
|
||||
final encryptedBody = <String, dynamic>{
|
||||
'algorithm': AlgorithmTypes.olmV1Curve25519AesSha2,
|
||||
'sender_key': identityKey,
|
||||
'ciphertext': <String, dynamic>{},
|
||||
};
|
||||
encryptedBody['ciphertext'][device.curve25519Key] = {
|
||||
'type': encryptResult.type,
|
||||
'body': encryptResult.body,
|
||||
};
|
||||
return encryptedBody;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> encryptToDeviceMessage(
|
||||
List<DeviceKeys> deviceKeys,
|
||||
String type,
|
||||
Map<String, dynamic> payload) async {
|
||||
final data = <String, Map<String, Map<String, dynamic>>>{};
|
||||
// first check if any of our sessions we want to encrypt for are in the database
|
||||
if (client.database != null) {
|
||||
await getOlmSessionsForDevicesFromDatabase(
|
||||
deviceKeys.map((d) => d.curve25519Key!).toList());
|
||||
}
|
||||
final deviceKeysWithoutSession = List<DeviceKeys>.from(deviceKeys);
|
||||
deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) =>
|
||||
olmSessions[deviceKeys.curve25519Key]?.isNotEmpty ?? false);
|
||||
if (deviceKeysWithoutSession.isNotEmpty) {
|
||||
await startOutgoingOlmSessions(deviceKeysWithoutSession);
|
||||
}
|
||||
for (final device in deviceKeys) {
|
||||
final userData = data[device.userId] ??= {};
|
||||
try {
|
||||
userData[device.deviceId!] = await encryptToDeviceMessagePayload(
|
||||
device, type, payload,
|
||||
getFromDb: false);
|
||||
} catch (e, s) {
|
||||
Logs().w('[LibOlm] Error encrypting to-device event', e, s);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
||||
if (event.type == EventTypes.Dummy) {
|
||||
// We receive dan encrypted m.dummy. This means that the other end was not able to
|
||||
// decrypt our last message. So, we re-send it.
|
||||
final encryptedContent = event.encryptedContent;
|
||||
if (encryptedContent == null || client.database == null) {
|
||||
return;
|
||||
}
|
||||
final device = client.getUserDeviceKeysByCurve25519Key(
|
||||
encryptedContent.tryGet<String>('sender_key') ?? '');
|
||||
if (device == null) {
|
||||
return; // device not found
|
||||
}
|
||||
Logs().v(
|
||||
'[OlmManager] Device ${device.userId}:${device.deviceId} generated a new olm session, replaying last sent message...');
|
||||
final lastSentMessageRes = await client.database
|
||||
?.getLastSentMessageUserDeviceKey(device.userId, device.deviceId!);
|
||||
if (lastSentMessageRes == null ||
|
||||
lastSentMessageRes.isEmpty ||
|
||||
lastSentMessageRes.first.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final lastSentMessage = json.decode(lastSentMessageRes.first);
|
||||
// We do *not* want to re-play m.dummy events, as they hold no value except of saying
|
||||
// what olm session is the most recent one. In fact, if we *do* replay them, then
|
||||
// we can easily land in an infinite ping-pong trap!
|
||||
if (lastSentMessage['type'] != EventTypes.Dummy) {
|
||||
// okay, time to send the message!
|
||||
await client.sendToDeviceEncrypted(
|
||||
[device], lastSentMessage['type'], lastSentMessage['content']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final sessions in olmSessions.values) {
|
||||
for (final sess in sessions) {
|
||||
sess.dispose();
|
||||
}
|
||||
}
|
||||
_olmAccount?.free();
|
||||
_olmAccount = null;
|
||||
}
|
||||
}
|
||||
755
lib/encryption/ssss.dart
Normal file
755
lib/encryption/ssss.dart
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:base58check/base58.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:matrix/encryption/utils/base64_unpadded.dart';
|
||||
|
||||
import '../matrix.dart';
|
||||
import '../src/utils/crypto/crypto.dart' as uc;
|
||||
import '../src/utils/run_in_root.dart';
|
||||
import 'encryption.dart';
|
||||
import 'utils/ssss_cache.dart';
|
||||
|
||||
const cacheTypes = <String>{
|
||||
EventTypes.CrossSigningSelfSigning,
|
||||
EventTypes.CrossSigningUserSigning,
|
||||
EventTypes.MegolmBackup,
|
||||
};
|
||||
|
||||
const zeroStr =
|
||||
'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00';
|
||||
const base58Alphabet =
|
||||
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
const base58 = Base58Codec(base58Alphabet);
|
||||
const olmRecoveryKeyPrefix = [0x8B, 0x01];
|
||||
const ssssKeyLength = 32;
|
||||
const pbkdf2DefaultIterations = 500000;
|
||||
const pbkdf2SaltLength = 64;
|
||||
|
||||
/// SSSS: **S**ecure **S**ecret **S**torage and **S**haring
|
||||
/// Read more about SSSS at:
|
||||
/// https://matrix.org/docs/guides/implementing-more-advanced-e-2-ee-features-such-as-cross-signing#3-implementing-ssss
|
||||
class SSSS {
|
||||
final Encryption encryption;
|
||||
|
||||
Client get client => encryption.client;
|
||||
final pendingShareRequests = <String, _ShareRequest>{};
|
||||
final _validators = <String, FutureOr<bool> Function(String)>{};
|
||||
final _cacheCallbacks = <String, FutureOr<void> Function(String)>{};
|
||||
final Map<String, SSSSCache> _cache = <String, SSSSCache>{};
|
||||
|
||||
SSSS(this.encryption);
|
||||
|
||||
// for testing
|
||||
Future<void> clearCache() async {
|
||||
await client.database?.clearSSSSCache();
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
static _DerivedKeys deriveKeys(Uint8List key, String name) {
|
||||
final zerosalt = Uint8List(8);
|
||||
final prk = Hmac(sha256, zerosalt).convert(key);
|
||||
final b = Uint8List(1);
|
||||
b[0] = 1;
|
||||
final aesKey = Hmac(sha256, prk.bytes).convert(utf8.encode(name) + b);
|
||||
b[0] = 2;
|
||||
final hmacKey =
|
||||
Hmac(sha256, prk.bytes).convert(aesKey.bytes + utf8.encode(name) + b);
|
||||
return _DerivedKeys(
|
||||
aesKey: Uint8List.fromList(aesKey.bytes),
|
||||
hmacKey: Uint8List.fromList(hmacKey.bytes));
|
||||
}
|
||||
|
||||
static Future<_Encrypted> encryptAes(String data, Uint8List key, String name,
|
||||
[String? ivStr]) async {
|
||||
Uint8List iv;
|
||||
if (ivStr != null) {
|
||||
iv = base64decodeUnpadded(ivStr);
|
||||
} else {
|
||||
iv = Uint8List.fromList(uc.secureRandomBytes(16));
|
||||
}
|
||||
// we need to clear bit 63 of the IV
|
||||
iv[8] &= 0x7f;
|
||||
|
||||
final keys = deriveKeys(key, name);
|
||||
|
||||
final plain = Uint8List.fromList(utf8.encode(data));
|
||||
final ciphertext = await uc.aesCtr.encrypt(plain, keys.aesKey, iv);
|
||||
|
||||
final hmac = Hmac(sha256, keys.hmacKey).convert(ciphertext);
|
||||
|
||||
return _Encrypted(
|
||||
iv: base64.encode(iv),
|
||||
ciphertext: base64.encode(ciphertext),
|
||||
mac: base64.encode(hmac.bytes));
|
||||
}
|
||||
|
||||
static Future<String> decryptAes(
|
||||
_Encrypted data, Uint8List key, String name) async {
|
||||
final keys = deriveKeys(key, name);
|
||||
final cipher = base64decodeUnpadded(data.ciphertext);
|
||||
final hmac = base64
|
||||
.encode(Hmac(sha256, keys.hmacKey).convert(cipher).bytes)
|
||||
.replaceAll(RegExp(r'=+$'), '');
|
||||
if (hmac != data.mac.replaceAll(RegExp(r'=+$'), '')) {
|
||||
throw Exception('Bad MAC');
|
||||
}
|
||||
final decipher = await uc.aesCtr
|
||||
.encrypt(cipher, keys.aesKey, base64decodeUnpadded(data.iv));
|
||||
return String.fromCharCodes(decipher);
|
||||
}
|
||||
|
||||
static Uint8List decodeRecoveryKey(String recoveryKey) {
|
||||
final result = base58.decode(recoveryKey.replaceAll(RegExp(r'\s'), ''));
|
||||
|
||||
final parity = result.fold<int>(0, (a, b) => a ^ b);
|
||||
if (parity != 0) {
|
||||
throw Exception('Incorrect parity');
|
||||
}
|
||||
|
||||
for (var i = 0; i < olmRecoveryKeyPrefix.length; i++) {
|
||||
if (result[i] != olmRecoveryKeyPrefix[i]) {
|
||||
throw Exception('Incorrect prefix');
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length != olmRecoveryKeyPrefix.length + ssssKeyLength + 1) {
|
||||
throw Exception('Incorrect length');
|
||||
}
|
||||
|
||||
return Uint8List.fromList(result.sublist(olmRecoveryKeyPrefix.length,
|
||||
olmRecoveryKeyPrefix.length + ssssKeyLength));
|
||||
}
|
||||
|
||||
static String encodeRecoveryKey(Uint8List recoveryKey) {
|
||||
final keyToEncode = <int>[...olmRecoveryKeyPrefix, ...recoveryKey];
|
||||
final parity = keyToEncode.fold<int>(0, (a, b) => a ^ b);
|
||||
keyToEncode.add(parity);
|
||||
// base58-encode and add a space every four chars
|
||||
return base58
|
||||
.encode(keyToEncode)
|
||||
.replaceAllMapped(RegExp(r'.{4}'), (s) => '${s.group(0)} ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
static Future<Uint8List> keyFromPassphrase(
|
||||
String passphrase, PassphraseInfo info) async {
|
||||
if (info.algorithm != AlgorithmTypes.pbkdf2) {
|
||||
throw Exception('Unknown algorithm');
|
||||
}
|
||||
if (info.iterations == null) {
|
||||
throw Exception('Passphrase info without iterations');
|
||||
}
|
||||
if (info.salt == null) {
|
||||
throw Exception('Passphrase info without salt');
|
||||
}
|
||||
return await uc.pbkdf2(
|
||||
Uint8List.fromList(utf8.encode(passphrase)),
|
||||
Uint8List.fromList(utf8.encode(info.salt!)),
|
||||
uc.sha512,
|
||||
info.iterations!,
|
||||
info.bits ?? 256);
|
||||
}
|
||||
|
||||
void setValidator(String type, FutureOr<bool> Function(String) validator) {
|
||||
_validators[type] = validator;
|
||||
}
|
||||
|
||||
void setCacheCallback(String type, FutureOr<void> Function(String) callback) {
|
||||
_cacheCallbacks[type] = callback;
|
||||
}
|
||||
|
||||
String? get defaultKeyId => client
|
||||
.accountData[EventTypes.SecretStorageDefaultKey]
|
||||
?.parsedSecretStorageDefaultKeyContent
|
||||
.key;
|
||||
|
||||
Future<void> setDefaultKeyId(String keyId) async {
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
EventTypes.SecretStorageDefaultKey,
|
||||
SecretStorageDefaultKeyContent(key: keyId).toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
SecretStorageKeyContent? getKey(String keyId) {
|
||||
return client.accountData[EventTypes.secretStorageKey(keyId)]
|
||||
?.parsedSecretStorageKeyContent;
|
||||
}
|
||||
|
||||
bool isKeyValid(String keyId) =>
|
||||
getKey(keyId)?.algorithm == AlgorithmTypes.secretStorageV1AesHmcSha2;
|
||||
|
||||
/// Creates a new secret storage key, optional encrypts it with [passphrase]
|
||||
/// and stores it in the user's `accountData`.
|
||||
Future<OpenSSSS> createKey([String? passphrase]) async {
|
||||
Uint8List privateKey;
|
||||
final content = SecretStorageKeyContent();
|
||||
if (passphrase != null) {
|
||||
// we need to derive the key off of the passphrase
|
||||
content.passphrase = PassphraseInfo(
|
||||
iterations: pbkdf2DefaultIterations,
|
||||
salt: base64.encode(uc.secureRandomBytes(pbkdf2SaltLength)),
|
||||
algorithm: AlgorithmTypes.pbkdf2,
|
||||
bits: ssssKeyLength * 8,
|
||||
);
|
||||
privateKey = await client
|
||||
.runInBackground(
|
||||
_keyFromPassphrase,
|
||||
_KeyFromPassphraseArgs(
|
||||
passphrase: passphrase,
|
||||
info: content.passphrase!,
|
||||
),
|
||||
)
|
||||
.timeout(Duration(seconds: 10));
|
||||
} else {
|
||||
// we need to just generate a new key from scratch
|
||||
privateKey = Uint8List.fromList(uc.secureRandomBytes(ssssKeyLength));
|
||||
}
|
||||
// now that we have the private key, let's create the iv and mac
|
||||
final encrypted = await encryptAes(zeroStr, privateKey, '');
|
||||
content.iv = encrypted.iv;
|
||||
content.mac = encrypted.mac;
|
||||
content.algorithm = AlgorithmTypes.secretStorageV1AesHmcSha2;
|
||||
|
||||
const keyidByteLength = 24;
|
||||
|
||||
// make sure we generate a unique key id
|
||||
final keyId = () sync* {
|
||||
for (;;) {
|
||||
yield base64.encode(uc.secureRandomBytes(keyidByteLength));
|
||||
}
|
||||
}()
|
||||
.firstWhere((keyId) => getKey(keyId) == null);
|
||||
|
||||
final accountDataType = EventTypes.secretStorageKey(keyId);
|
||||
// noooow we set the account data
|
||||
final waitForAccountData = client.onSync.stream.firstWhere((syncUpdate) =>
|
||||
syncUpdate.accountData != null &&
|
||||
syncUpdate.accountData!
|
||||
.any((accountData) => accountData.type == accountDataType));
|
||||
await client.setAccountData(
|
||||
client.userID!, accountDataType, content.toJson());
|
||||
await waitForAccountData;
|
||||
|
||||
final key = open(keyId);
|
||||
await key.setPrivateKey(privateKey);
|
||||
return key;
|
||||
}
|
||||
|
||||
Future<bool> checkKey(Uint8List key, SecretStorageKeyContent info) async {
|
||||
if (info.algorithm == AlgorithmTypes.secretStorageV1AesHmcSha2) {
|
||||
if ((info.mac is String) && (info.iv is String)) {
|
||||
final encrypted = await encryptAes(zeroStr, key, '', info.iv);
|
||||
return info.mac!.replaceAll(RegExp(r'=+$'), '') ==
|
||||
encrypted.mac.replaceAll(RegExp(r'=+$'), '');
|
||||
} else {
|
||||
// no real information about the key, assume it is valid
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
throw Exception('Unknown Algorithm');
|
||||
}
|
||||
}
|
||||
|
||||
bool isSecret(String type) =>
|
||||
client.accountData[type] != null &&
|
||||
client.accountData[type]!.content['encrypted'] is Map;
|
||||
|
||||
Future<String?> getCached(String type) async {
|
||||
if (client.database == null) {
|
||||
return null;
|
||||
}
|
||||
// check if it is still valid
|
||||
final keys = keyIdsFromType(type);
|
||||
if (keys == null) {
|
||||
return null;
|
||||
}
|
||||
final isValid = (dbEntry) =>
|
||||
keys.contains(dbEntry.keyId) &&
|
||||
dbEntry.ciphertext != null &&
|
||||
client.accountData[type]?.content['encrypted'][dbEntry.keyId]
|
||||
['ciphertext'] ==
|
||||
dbEntry.ciphertext;
|
||||
if (_cache.containsKey(type) && isValid(_cache[type])) {
|
||||
return _cache[type]?.content;
|
||||
}
|
||||
final ret = await client.database?.getSSSSCache(type);
|
||||
if (ret == null) {
|
||||
return null;
|
||||
}
|
||||
if (isValid(ret)) {
|
||||
_cache[type] = ret;
|
||||
return ret.content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> getStored(String type, String keyId, Uint8List key) async {
|
||||
final secretInfo = client.accountData[type];
|
||||
if (secretInfo == null) {
|
||||
throw Exception('Not found');
|
||||
}
|
||||
if (!(secretInfo.content['encrypted'] is Map)) {
|
||||
throw Exception('Content is not encrypted');
|
||||
}
|
||||
if (!(secretInfo.content['encrypted'][keyId] is Map)) {
|
||||
throw Exception('Wrong / unknown key');
|
||||
}
|
||||
final enc = secretInfo.content['encrypted'][keyId];
|
||||
final encryptInfo = _Encrypted(
|
||||
iv: enc['iv'], ciphertext: enc['ciphertext'], mac: enc['mac']);
|
||||
final decrypted = await decryptAes(encryptInfo, key, type);
|
||||
final db = client.database;
|
||||
if (cacheTypes.contains(type) && db != null) {
|
||||
// cache the thing
|
||||
await db.storeSSSSCache(type, keyId, enc['ciphertext'], decrypted);
|
||||
if (_cacheCallbacks.containsKey(type) && await getCached(type) == null) {
|
||||
_cacheCallbacks[type]!(decrypted);
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
Future<void> store(String type, String secret, String keyId, Uint8List key,
|
||||
{bool add = false}) async {
|
||||
final encrypted = await encryptAes(secret, key, type);
|
||||
Map<String, dynamic>? content;
|
||||
if (add && client.accountData[type] != null) {
|
||||
content = client.accountData[type]!.content.copy();
|
||||
if (!(content['encrypted'] is Map)) {
|
||||
content['encrypted'] = <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
content ??= <String, dynamic>{
|
||||
'encrypted': <String, dynamic>{},
|
||||
};
|
||||
content['encrypted'][keyId] = <String, dynamic>{
|
||||
'iv': encrypted.iv,
|
||||
'ciphertext': encrypted.ciphertext,
|
||||
'mac': encrypted.mac,
|
||||
};
|
||||
// store the thing in your account data
|
||||
await client.setAccountData(client.userID!, type, content);
|
||||
final db = client.database;
|
||||
if (cacheTypes.contains(type) && db != null) {
|
||||
// cache the thing
|
||||
await db.storeSSSSCache(type, keyId, encrypted.ciphertext, secret);
|
||||
if (_cacheCallbacks.containsKey(type) && await getCached(type) == null) {
|
||||
_cacheCallbacks[type]!(secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> validateAndStripOtherKeys(
|
||||
String type, String secret, String keyId, Uint8List key) async {
|
||||
if (await getStored(type, keyId, key) != secret) {
|
||||
throw Exception('Secrets do not match up!');
|
||||
}
|
||||
// now remove all other keys
|
||||
final content = client.accountData[type]?.content.copy();
|
||||
if (content == null) {
|
||||
throw Exception('Key has no content!');
|
||||
}
|
||||
|
||||
final otherKeys =
|
||||
Set<String>.from(content['encrypted'].keys.where((k) => k != keyId));
|
||||
content['encrypted'].removeWhere((k, v) => otherKeys.contains(k));
|
||||
// yes, we are paranoid...
|
||||
if (await getStored(type, keyId, key) != secret) {
|
||||
throw Exception('Secrets do not match up!');
|
||||
}
|
||||
// store the thing in your account data
|
||||
await client.setAccountData(client.userID!, type, content);
|
||||
if (cacheTypes.contains(type)) {
|
||||
// cache the thing
|
||||
await client.database?.storeSSSSCache(
|
||||
type, keyId, content['encrypted'][keyId]['ciphertext'], secret);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> maybeCacheAll(String keyId, Uint8List key) async {
|
||||
for (final type in cacheTypes) {
|
||||
final secret = await getCached(type);
|
||||
if (secret == null) {
|
||||
try {
|
||||
await getStored(type, keyId, key);
|
||||
} catch (_) {
|
||||
// the entry wasn't stored, just ignore it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> maybeRequestAll([List<DeviceKeys>? devices]) async {
|
||||
for (final type in cacheTypes) {
|
||||
if (keyIdsFromType(type) != null) {
|
||||
final secret = await getCached(type);
|
||||
if (secret == null) {
|
||||
await request(type, devices);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> request(String type, [List<DeviceKeys>? devices]) async {
|
||||
// only send to own, verified devices
|
||||
Logs().i('[SSSS] Requesting type $type...');
|
||||
if (devices == null || devices.isEmpty) {
|
||||
if (!client.userDeviceKeys.containsKey(client.userID)) {
|
||||
Logs().w('[SSSS] User does not have any devices');
|
||||
return;
|
||||
}
|
||||
devices =
|
||||
client.userDeviceKeys[client.userID]!.deviceKeys.values.toList();
|
||||
}
|
||||
devices.removeWhere((DeviceKeys d) =>
|
||||
d.userId != client.userID ||
|
||||
!d.verified ||
|
||||
d.blocked ||
|
||||
d.deviceId == client.deviceID);
|
||||
if (devices.isEmpty) {
|
||||
Logs().w('[SSSS] No devices');
|
||||
return;
|
||||
}
|
||||
final requestId = client.generateUniqueTransactionId();
|
||||
final request = _ShareRequest(
|
||||
requestId: requestId,
|
||||
type: type,
|
||||
devices: devices,
|
||||
);
|
||||
pendingShareRequests[requestId] = request;
|
||||
await client.sendToDeviceEncrypted(devices, EventTypes.SecretRequest, {
|
||||
'action': 'request',
|
||||
'requesting_device_id': client.deviceID,
|
||||
'request_id': requestId,
|
||||
'name': type,
|
||||
});
|
||||
}
|
||||
|
||||
DateTime? _lastCacheRequest;
|
||||
bool _isPeriodicallyRequestingMissingCache = false;
|
||||
|
||||
Future<void> periodicallyRequestMissingCache() async {
|
||||
if (_isPeriodicallyRequestingMissingCache ||
|
||||
(_lastCacheRequest != null &&
|
||||
DateTime.now()
|
||||
.subtract(Duration(minutes: 15))
|
||||
.isBefore(_lastCacheRequest!)) ||
|
||||
client.isUnknownSession) {
|
||||
// we are already requesting right now or we attempted to within the last 15 min
|
||||
return;
|
||||
}
|
||||
_lastCacheRequest = DateTime.now();
|
||||
_isPeriodicallyRequestingMissingCache = true;
|
||||
try {
|
||||
await maybeRequestAll();
|
||||
} finally {
|
||||
_isPeriodicallyRequestingMissingCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
||||
if (event.type == EventTypes.SecretRequest) {
|
||||
// got a request to share a secret
|
||||
Logs().i('[SSSS] Received sharing request...');
|
||||
if (event.sender != client.userID ||
|
||||
!client.userDeviceKeys.containsKey(client.userID)) {
|
||||
Logs().i('[SSSS] Not sent by us');
|
||||
return; // we aren't asking for it ourselves, so ignore
|
||||
}
|
||||
if (event.content['action'] != 'request') {
|
||||
Logs().i('[SSSS] it is actually a cancelation');
|
||||
return; // not actually requesting, so ignore
|
||||
}
|
||||
final device = client.userDeviceKeys[client.userID]!
|
||||
.deviceKeys[event.content['requesting_device_id']];
|
||||
if (device == null || !device.verified || device.blocked) {
|
||||
Logs().i('[SSSS] Unknown / unverified devices, ignoring');
|
||||
return; // nope....unknown or untrusted device
|
||||
}
|
||||
// alright, all seems fine...let's check if we actually have the secret they are asking for
|
||||
final type = event.content['name'];
|
||||
final secret = await getCached(type);
|
||||
if (secret == null) {
|
||||
Logs()
|
||||
.i('[SSSS] We don\'t have the secret for $type ourself, ignoring');
|
||||
return; // seems like we don't have this, either
|
||||
}
|
||||
// okay, all checks out...time to share this secret!
|
||||
Logs().i('[SSSS] Replying with secret for $type');
|
||||
await client.sendToDeviceEncrypted(
|
||||
[device],
|
||||
EventTypes.SecretSend,
|
||||
{
|
||||
'request_id': event.content['request_id'],
|
||||
'secret': secret,
|
||||
});
|
||||
} else if (event.type == EventTypes.SecretSend) {
|
||||
// receiving a secret we asked for
|
||||
Logs().i('[SSSS] Received shared secret...');
|
||||
final encryptedContent = event.encryptedContent;
|
||||
if (event.sender != client.userID ||
|
||||
!pendingShareRequests.containsKey(event.content['request_id']) ||
|
||||
encryptedContent == null) {
|
||||
Logs().i('[SSSS] Not by us or unknown request');
|
||||
return; // we have no idea what we just received
|
||||
}
|
||||
final request = pendingShareRequests[event.content['request_id']]!;
|
||||
// alright, as we received a known request id, let's check if the sender is valid
|
||||
final device = request.devices.firstWhereOrNull((d) =>
|
||||
d.userId == event.sender &&
|
||||
d.curve25519Key == encryptedContent['sender_key']);
|
||||
if (device == null) {
|
||||
Logs().i('[SSSS] Someone else replied?');
|
||||
return; // someone replied whom we didn't send the share request to
|
||||
}
|
||||
final secret = event.content['secret'];
|
||||
if (!(event.content['secret'] is String)) {
|
||||
Logs().i('[SSSS] Secret wasn\'t a string');
|
||||
return; // the secret wasn't a string....wut?
|
||||
}
|
||||
// let's validate if the secret is, well, valid
|
||||
if (_validators.containsKey(request.type) &&
|
||||
!(await _validators[request.type]!(secret))) {
|
||||
Logs().i('[SSSS] The received secret was invalid');
|
||||
return; // didn't pass the validator
|
||||
}
|
||||
pendingShareRequests.remove(request.requestId);
|
||||
if (request.start.add(Duration(minutes: 15)).isBefore(DateTime.now())) {
|
||||
Logs().i('[SSSS] Request is too far in the past');
|
||||
return; // our request is more than 15min in the past...better not trust it anymore
|
||||
}
|
||||
Logs().i('[SSSS] Secret for type ${request.type} is ok, storing it');
|
||||
final db = client.database;
|
||||
if (db != null) {
|
||||
final keyId = keyIdFromType(request.type);
|
||||
if (keyId != null) {
|
||||
final ciphertext = client.accountData[request.type]!
|
||||
.content['encrypted'][keyId]['ciphertext'];
|
||||
await db.storeSSSSCache(request.type, keyId, ciphertext, secret);
|
||||
if (_cacheCallbacks.containsKey(request.type)) {
|
||||
_cacheCallbacks[request.type]!(secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set<String>? keyIdsFromType(String type) {
|
||||
final data = client.accountData[type];
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
if (data.content['encrypted'] is Map) {
|
||||
return data.content['encrypted'].keys.toSet();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? keyIdFromType(String type) {
|
||||
final keys = keyIdsFromType(type);
|
||||
if (keys == null || keys.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (keys.contains(defaultKeyId)) {
|
||||
return defaultKeyId;
|
||||
}
|
||||
return keys.first;
|
||||
}
|
||||
|
||||
OpenSSSS open([String? identifier]) {
|
||||
identifier ??= defaultKeyId;
|
||||
if (identifier == null) {
|
||||
throw Exception('Dont know what to open');
|
||||
}
|
||||
final keyToOpen = keyIdFromType(identifier) ?? identifier;
|
||||
final key = getKey(keyToOpen);
|
||||
if (key == null) {
|
||||
throw Exception('Unknown key to open');
|
||||
}
|
||||
return OpenSSSS(ssss: this, keyId: keyToOpen, keyData: key);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareRequest {
|
||||
final String requestId;
|
||||
final String type;
|
||||
final List<DeviceKeys> devices;
|
||||
final DateTime start;
|
||||
|
||||
_ShareRequest(
|
||||
{required this.requestId, required this.type, required this.devices})
|
||||
: start = DateTime.now();
|
||||
}
|
||||
|
||||
class _Encrypted {
|
||||
final String iv;
|
||||
final String ciphertext;
|
||||
final String mac;
|
||||
|
||||
_Encrypted({required this.iv, required this.ciphertext, required this.mac});
|
||||
}
|
||||
|
||||
class _DerivedKeys {
|
||||
final Uint8List aesKey;
|
||||
final Uint8List hmacKey;
|
||||
|
||||
_DerivedKeys({required this.aesKey, required this.hmacKey});
|
||||
}
|
||||
|
||||
class OpenSSSS {
|
||||
final SSSS ssss;
|
||||
final String keyId;
|
||||
final SecretStorageKeyContent keyData;
|
||||
|
||||
OpenSSSS({required this.ssss, required this.keyId, required this.keyData});
|
||||
|
||||
Uint8List? privateKey;
|
||||
|
||||
bool get isUnlocked => privateKey != null;
|
||||
|
||||
bool get hasPassphrase => keyData.passphrase != null;
|
||||
|
||||
String? get recoveryKey =>
|
||||
isUnlocked ? SSSS.encodeRecoveryKey(privateKey!) : null;
|
||||
|
||||
Future<void> unlock(
|
||||
{String? passphrase,
|
||||
String? recoveryKey,
|
||||
String? keyOrPassphrase,
|
||||
bool postUnlock = true}) async {
|
||||
if (keyOrPassphrase != null) {
|
||||
try {
|
||||
await unlock(recoveryKey: keyOrPassphrase, postUnlock: postUnlock);
|
||||
} catch (_) {
|
||||
if (hasPassphrase) {
|
||||
await unlock(passphrase: keyOrPassphrase, postUnlock: postUnlock);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else if (passphrase != null) {
|
||||
if (!hasPassphrase) {
|
||||
throw Exception(
|
||||
'Tried to unlock with passphrase while key does not have a passphrase');
|
||||
}
|
||||
privateKey = await ssss.client
|
||||
.runInBackground(
|
||||
_keyFromPassphrase,
|
||||
_KeyFromPassphraseArgs(
|
||||
passphrase: passphrase,
|
||||
info: keyData.passphrase!,
|
||||
),
|
||||
)
|
||||
.timeout(Duration(seconds: 10));
|
||||
} else if (recoveryKey != null) {
|
||||
privateKey = SSSS.decodeRecoveryKey(recoveryKey);
|
||||
} else {
|
||||
throw Exception('Nothing specified');
|
||||
}
|
||||
// verify the validity of the key
|
||||
if (!await ssss.checkKey(privateKey!, keyData)) {
|
||||
privateKey = null;
|
||||
throw Exception('Inalid key');
|
||||
}
|
||||
if (postUnlock) {
|
||||
await runInRoot(() => _postUnlock());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setPrivateKey(Uint8List key) async {
|
||||
if (!await ssss.checkKey(key, keyData)) {
|
||||
throw Exception('Invalid key');
|
||||
}
|
||||
privateKey = key;
|
||||
}
|
||||
|
||||
Future<String> getStored(String type) async {
|
||||
final privateKey = this.privateKey;
|
||||
if (privateKey == null) {
|
||||
throw Exception('SSSS not unlocked');
|
||||
}
|
||||
return await ssss.getStored(type, keyId, privateKey);
|
||||
}
|
||||
|
||||
Future<void> store(String type, String secret, {bool add = false}) async {
|
||||
final privateKey = this.privateKey;
|
||||
if (privateKey == null) {
|
||||
throw Exception('SSSS not unlocked');
|
||||
}
|
||||
await ssss.store(type, secret, keyId, privateKey, add: add);
|
||||
}
|
||||
|
||||
Future<void> validateAndStripOtherKeys(String type, String secret) async {
|
||||
final privateKey = this.privateKey;
|
||||
if (privateKey == null) {
|
||||
throw Exception('SSSS not unlocked');
|
||||
}
|
||||
await ssss.validateAndStripOtherKeys(type, secret, keyId, privateKey);
|
||||
}
|
||||
|
||||
Future<void> maybeCacheAll() async {
|
||||
final privateKey = this.privateKey;
|
||||
if (privateKey == null) {
|
||||
throw Exception('SSSS not unlocked');
|
||||
}
|
||||
await ssss.maybeCacheAll(keyId, privateKey);
|
||||
}
|
||||
|
||||
Future<void> _postUnlock() async {
|
||||
// first try to cache all secrets that aren't cached yet
|
||||
await maybeCacheAll();
|
||||
// now try to self-sign
|
||||
if (ssss.encryption.crossSigning.enabled &&
|
||||
ssss.client.userDeviceKeys[ssss.client.userID]?.masterKey != null &&
|
||||
(ssss
|
||||
.keyIdsFromType(EventTypes.CrossSigningMasterKey)
|
||||
?.contains(keyId) ??
|
||||
false) &&
|
||||
(ssss.client.isUnknownSession ||
|
||||
ssss.client.userDeviceKeys[ssss.client.userID]!.masterKey
|
||||
?.directVerified !=
|
||||
true)) {
|
||||
try {
|
||||
await ssss.encryption.crossSigning.selfSign(openSsss: this);
|
||||
} catch (e, s) {
|
||||
Logs().e('[SSSS] Failed to self-sign', e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyFromPassphraseArgs {
|
||||
final String passphrase;
|
||||
final PassphraseInfo info;
|
||||
|
||||
_KeyFromPassphraseArgs({required this.passphrase, required this.info});
|
||||
}
|
||||
|
||||
Future<Uint8List> _keyFromPassphrase(_KeyFromPassphraseArgs args) async {
|
||||
return await SSSS.keyFromPassphrase(args.passphrase, args.info);
|
||||
}
|
||||
13
lib/encryption/utils/base64_unpadded.dart
Normal file
13
lib/encryption/utils/base64_unpadded.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// decodes base64
|
||||
///
|
||||
/// Dart's native [base64.decode] requires a padded base64 input String.
|
||||
/// This function allows unpadded base64 too.
|
||||
///
|
||||
/// See: https://github.com/dart-lang/sdk/issues/39510
|
||||
Uint8List base64decodeUnpadded(String s) {
|
||||
final needEquals = (4 - (s.length % 4)) % 4;
|
||||
return base64.decode(s + ('=' * needEquals));
|
||||
}
|
||||
606
lib/encryption/utils/bootstrap.dart
Normal file
606
lib/encryption/utils/bootstrap.dart
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 'dart:typed_data';
|
||||
|
||||
import 'package:canonical_json/canonical_json.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../encryption.dart';
|
||||
import '../ssss.dart';
|
||||
import '../key_manager.dart';
|
||||
import '../../matrix.dart';
|
||||
import 'base64_unpadded.dart';
|
||||
|
||||
enum BootstrapState {
|
||||
/// Is loading.
|
||||
loading,
|
||||
|
||||
/// Existing SSSS found, should we wipe it?
|
||||
askWipeSsss,
|
||||
|
||||
/// Ask if an existing SSSS should be userDeviceKeys
|
||||
askUseExistingSsss,
|
||||
|
||||
/// Ask to unlock all the SSSS keys
|
||||
askUnlockSsss,
|
||||
|
||||
/// SSSS is in a bad state, continue with potential dataloss?
|
||||
askBadSsss,
|
||||
|
||||
/// Ask for new SSSS key / passphrase
|
||||
askNewSsss,
|
||||
|
||||
/// Open an existing SSSS key
|
||||
openExistingSsss,
|
||||
|
||||
/// Ask if cross signing should be wiped
|
||||
askWipeCrossSigning,
|
||||
|
||||
/// Ask if cross signing should be set up
|
||||
askSetupCrossSigning,
|
||||
|
||||
/// Ask if online key backup should be wiped
|
||||
askWipeOnlineKeyBackup,
|
||||
|
||||
/// Ask if the online key backup should be set up
|
||||
askSetupOnlineKeyBackup,
|
||||
|
||||
/// An error has been occured.
|
||||
error,
|
||||
|
||||
/// done
|
||||
done,
|
||||
}
|
||||
|
||||
/// Bootstrapping SSSS and cross-signing
|
||||
class Bootstrap {
|
||||
final Encryption encryption;
|
||||
Client get client => encryption.client;
|
||||
void Function()? onUpdate;
|
||||
BootstrapState get state => _state;
|
||||
BootstrapState _state = BootstrapState.loading;
|
||||
Map<String, OpenSSSS>? oldSsssKeys;
|
||||
OpenSSSS? newSsssKey;
|
||||
Map<String, String>? secretMap;
|
||||
|
||||
Bootstrap({required this.encryption, this.onUpdate}) {
|
||||
if (analyzeSecrets().isNotEmpty) {
|
||||
state = BootstrapState.askWipeSsss;
|
||||
} else {
|
||||
state = BootstrapState.askNewSsss;
|
||||
}
|
||||
}
|
||||
|
||||
// cache the secret analyzing so that we don't drop stuff a different client sets during bootstrapping
|
||||
Map<String, Set<String>>? _secretsCache;
|
||||
Map<String, Set<String>> analyzeSecrets() {
|
||||
final secretsCache = _secretsCache;
|
||||
if (secretsCache != null) {
|
||||
// deep-copy so that we can do modifications
|
||||
final newSecrets = <String, Set<String>>{};
|
||||
for (final s in secretsCache.entries) {
|
||||
newSecrets[s.key] = Set<String>.from(s.value);
|
||||
}
|
||||
return newSecrets;
|
||||
}
|
||||
final secrets = <String, Set<String>>{};
|
||||
for (final entry in client.accountData.entries) {
|
||||
final type = entry.key;
|
||||
final event = entry.value;
|
||||
if (!(event.content['encrypted'] is Map)) {
|
||||
continue;
|
||||
}
|
||||
final validKeys = <String>{};
|
||||
final invalidKeys = <String>{};
|
||||
for (final keyEntry in event.content['encrypted'].entries) {
|
||||
final key = keyEntry.key;
|
||||
final value = keyEntry.value;
|
||||
if (!(value is Map)) {
|
||||
// we don't add the key to invalidKeys as this was not a proper secret anyways!
|
||||
continue;
|
||||
}
|
||||
if (!(value['iv'] is String) ||
|
||||
!(value['ciphertext'] is String) ||
|
||||
!(value['mac'] is String)) {
|
||||
invalidKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
if (!encryption.ssss.isKeyValid(key)) {
|
||||
invalidKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
validKeys.add(key);
|
||||
}
|
||||
if (validKeys.isEmpty && invalidKeys.isEmpty) {
|
||||
continue; // this didn't contain any keys anyways!
|
||||
}
|
||||
// if there are no valid keys and only invalid keys then the validKeys set will be empty
|
||||
// from that we know that there were errors with this secret and that we won't be able to migrate it
|
||||
secrets[type] = validKeys;
|
||||
}
|
||||
_secretsCache = secrets;
|
||||
return analyzeSecrets();
|
||||
}
|
||||
|
||||
Set<String> badSecrets() {
|
||||
final secrets = analyzeSecrets();
|
||||
secrets.removeWhere((k, v) => v.isNotEmpty);
|
||||
return Set<String>.from(secrets.keys);
|
||||
}
|
||||
|
||||
String mostUsedKey(Map<String, Set<String>> secrets) {
|
||||
final usage = <String, int>{};
|
||||
for (final keys in secrets.values) {
|
||||
for (final key in keys) {
|
||||
usage.update(key, (i) => i + 1, ifAbsent: () => 1);
|
||||
}
|
||||
}
|
||||
final entriesList = usage.entries.toList();
|
||||
entriesList.sort((a, b) => a.value.compareTo(b.value));
|
||||
return entriesList.first.key;
|
||||
}
|
||||
|
||||
Set<String> allNeededKeys() {
|
||||
final secrets = analyzeSecrets();
|
||||
secrets.removeWhere(
|
||||
(k, v) => v.isEmpty); // we don't care about the failed secrets here
|
||||
final keys = <String>{};
|
||||
final defaultKeyId = encryption.ssss.defaultKeyId;
|
||||
final removeKey = (String key) {
|
||||
final sizeBefore = secrets.length;
|
||||
secrets.removeWhere((k, v) => v.contains(key));
|
||||
return sizeBefore - secrets.length;
|
||||
};
|
||||
// first we want to try the default key id
|
||||
if (defaultKeyId != null) {
|
||||
if (removeKey(defaultKeyId) > 0) {
|
||||
keys.add(defaultKeyId);
|
||||
}
|
||||
}
|
||||
// now we re-try as long as we have keys for all secrets
|
||||
while (secrets.isNotEmpty) {
|
||||
final key = mostUsedKey(secrets);
|
||||
removeKey(key);
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
void wipeSsss(bool wipe) {
|
||||
if (state != BootstrapState.askWipeSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
if (wipe) {
|
||||
state = BootstrapState.askNewSsss;
|
||||
} else if (encryption.ssss.defaultKeyId != null &&
|
||||
encryption.ssss.isKeyValid(encryption.ssss.defaultKeyId!)) {
|
||||
state = BootstrapState.askUseExistingSsss;
|
||||
} else if (badSecrets().isNotEmpty) {
|
||||
state = BootstrapState.askBadSsss;
|
||||
} else {
|
||||
migrateOldSsss();
|
||||
}
|
||||
}
|
||||
|
||||
void useExistingSsss(bool use) {
|
||||
if (state != BootstrapState.askUseExistingSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
if (use) {
|
||||
try {
|
||||
newSsssKey = encryption.ssss.open(encryption.ssss.defaultKeyId);
|
||||
state = BootstrapState.openExistingSsss;
|
||||
} catch (e, s) {
|
||||
Logs().e('[Bootstrapping] Error open SSSS', e, s);
|
||||
state = BootstrapState.error;
|
||||
return;
|
||||
}
|
||||
} else if (badSecrets().isNotEmpty) {
|
||||
state = BootstrapState.askBadSsss;
|
||||
} else {
|
||||
migrateOldSsss();
|
||||
}
|
||||
}
|
||||
|
||||
void ignoreBadSecrets(bool ignore) {
|
||||
if (state != BootstrapState.askBadSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
if (ignore) {
|
||||
migrateOldSsss();
|
||||
} else {
|
||||
// that's it, folks. We can't do anything here
|
||||
state = BootstrapState.error;
|
||||
}
|
||||
}
|
||||
|
||||
void migrateOldSsss() {
|
||||
final keys = allNeededKeys();
|
||||
final oldSsssKeys = this.oldSsssKeys = {};
|
||||
try {
|
||||
for (final key in keys) {
|
||||
oldSsssKeys[key] = encryption.ssss.open(key);
|
||||
}
|
||||
} catch (e, s) {
|
||||
Logs().e('[Bootstrapping] Error construction ssss key', e, s);
|
||||
state = BootstrapState.error;
|
||||
return;
|
||||
}
|
||||
state = BootstrapState.askUnlockSsss;
|
||||
}
|
||||
|
||||
void unlockedSsss() {
|
||||
if (state != BootstrapState.askUnlockSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
state = BootstrapState.askNewSsss;
|
||||
}
|
||||
|
||||
Future<void> newSsss([String? passphrase]) async {
|
||||
if (state != BootstrapState.askNewSsss) {
|
||||
throw BootstrapBadStateException('Wrong State');
|
||||
}
|
||||
state = BootstrapState.loading;
|
||||
try {
|
||||
Logs().v('Create key...');
|
||||
newSsssKey = await encryption.ssss.createKey(passphrase);
|
||||
if (oldSsssKeys != null) {
|
||||
// alright, we have to re-encrypt old secrets with the new key
|
||||
final secrets = analyzeSecrets();
|
||||
final removeKey = (String key) {
|
||||
final s = secrets.entries
|
||||
.where((e) => e.value.contains(key))
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
secrets.removeWhere((k, v) => v.contains(key));
|
||||
return s;
|
||||
};
|
||||
secretMap = <String, String>{};
|
||||
for (final entry in oldSsssKeys!.entries) {
|
||||
final key = entry.value;
|
||||
final keyId = entry.key;
|
||||
if (!key.isUnlocked) {
|
||||
continue;
|
||||
}
|
||||
for (final s in removeKey(keyId)) {
|
||||
Logs().v('Get stored key of type $s...');
|
||||
secretMap![s] = await key.getStored(s);
|
||||
Logs().v('Store new secret with this key...');
|
||||
await newSsssKey!.store(s, secretMap![s]!, add: true);
|
||||
}
|
||||
}
|
||||
// alright, we re-encrypted all the secrets. We delete the dead weight only *after* we set our key to the default key
|
||||
}
|
||||
final updatedAccountData = client.onSync.stream.firstWhere((syncUpdate) =>
|
||||
syncUpdate.accountData != null &&
|
||||
syncUpdate.accountData!.any((accountData) =>
|
||||
accountData.type == EventTypes.SecretStorageDefaultKey));
|
||||
await encryption.ssss.setDefaultKeyId(newSsssKey!.keyId);
|
||||
await updatedAccountData;
|
||||
if (oldSsssKeys != null) {
|
||||
for (final entry in secretMap!.entries) {
|
||||
Logs().v('Validate and stripe other keys ${entry.key}...');
|
||||
await newSsssKey!.validateAndStripOtherKeys(entry.key, entry.value);
|
||||
}
|
||||
Logs().v('And make super sure we have everything cached...');
|
||||
await newSsssKey!.maybeCacheAll();
|
||||
}
|
||||
} catch (e, s) {
|
||||
Logs().e('[Bootstrapping] Error trying to migrate old secrets', e, s);
|
||||
state = BootstrapState.error;
|
||||
return;
|
||||
}
|
||||
// alright, we successfully migrated all secrets, if needed
|
||||
|
||||
checkCrossSigning();
|
||||
}
|
||||
|
||||
Future<void> openExistingSsss() async {
|
||||
final newSsssKey = this.newSsssKey;
|
||||
if (state != BootstrapState.openExistingSsss || newSsssKey == null) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (!newSsssKey.isUnlocked) {
|
||||
throw BootstrapBadStateException('Key not unlocked');
|
||||
}
|
||||
Logs().v('Maybe cache all...');
|
||||
await newSsssKey.maybeCacheAll();
|
||||
checkCrossSigning();
|
||||
}
|
||||
|
||||
void checkCrossSigning() {
|
||||
// so, let's see if we have cross signing set up
|
||||
if (encryption.crossSigning.enabled) {
|
||||
// cross signing present, ask for wipe
|
||||
state = BootstrapState.askWipeCrossSigning;
|
||||
return;
|
||||
}
|
||||
// no cross signing present
|
||||
state = BootstrapState.askSetupCrossSigning;
|
||||
}
|
||||
|
||||
void wipeCrossSigning(bool wipe) {
|
||||
if (state != BootstrapState.askWipeCrossSigning) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (wipe) {
|
||||
state = BootstrapState.askSetupCrossSigning;
|
||||
} else {
|
||||
checkOnlineKeyBackup();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> askSetupCrossSigning(
|
||||
{bool setupMasterKey = false,
|
||||
bool setupSelfSigningKey = false,
|
||||
bool setupUserSigningKey = false}) async {
|
||||
if (state != BootstrapState.askSetupCrossSigning) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (!setupMasterKey && !setupSelfSigningKey && !setupUserSigningKey) {
|
||||
checkOnlineKeyBackup();
|
||||
return;
|
||||
}
|
||||
final userID = client.userID!;
|
||||
try {
|
||||
Uint8List masterSigningKey;
|
||||
final secretsToStore = <String, String>{};
|
||||
MatrixCrossSigningKey? masterKey;
|
||||
MatrixCrossSigningKey? selfSigningKey;
|
||||
MatrixCrossSigningKey? userSigningKey;
|
||||
String? masterPub;
|
||||
if (setupMasterKey) {
|
||||
final master = olm.PkSigning();
|
||||
try {
|
||||
masterSigningKey = master.generate_seed();
|
||||
masterPub = master.init_with_seed(masterSigningKey);
|
||||
final json = <String, dynamic>{
|
||||
'user_id': userID,
|
||||
'usage': ['master'],
|
||||
'keys': <String, dynamic>{
|
||||
'ed25519:$masterPub': masterPub,
|
||||
},
|
||||
};
|
||||
masterKey = MatrixCrossSigningKey.fromJson(json);
|
||||
secretsToStore[EventTypes.CrossSigningMasterKey] =
|
||||
base64.encode(masterSigningKey);
|
||||
} finally {
|
||||
master.free();
|
||||
}
|
||||
} else {
|
||||
Logs().v('Get stored key...');
|
||||
masterSigningKey = base64decodeUnpadded(
|
||||
await newSsssKey?.getStored(EventTypes.CrossSigningMasterKey) ??
|
||||
'');
|
||||
if (masterSigningKey.isEmpty) {
|
||||
// no master signing key :(
|
||||
throw BootstrapBadStateException('No master key');
|
||||
}
|
||||
final master = olm.PkSigning();
|
||||
try {
|
||||
masterPub = master.init_with_seed(masterSigningKey);
|
||||
} finally {
|
||||
master.free();
|
||||
}
|
||||
}
|
||||
final _sign = (Map<String, dynamic> object) {
|
||||
final keyObj = olm.PkSigning();
|
||||
try {
|
||||
keyObj.init_with_seed(masterSigningKey);
|
||||
return keyObj
|
||||
.sign(String.fromCharCodes(canonicalJson.encode(object)));
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
};
|
||||
if (setupSelfSigningKey) {
|
||||
final selfSigning = olm.PkSigning();
|
||||
try {
|
||||
final selfSigningPriv = selfSigning.generate_seed();
|
||||
final selfSigningPub = selfSigning.init_with_seed(selfSigningPriv);
|
||||
final json = <String, dynamic>{
|
||||
'user_id': userID,
|
||||
'usage': ['self_signing'],
|
||||
'keys': <String, dynamic>{
|
||||
'ed25519:$selfSigningPub': selfSigningPub,
|
||||
},
|
||||
};
|
||||
final signature = _sign(json);
|
||||
json['signatures'] = <String, dynamic>{
|
||||
userID: <String, dynamic>{
|
||||
'ed25519:$masterPub': signature,
|
||||
},
|
||||
};
|
||||
selfSigningKey = MatrixCrossSigningKey.fromJson(json);
|
||||
secretsToStore[EventTypes.CrossSigningSelfSigning] =
|
||||
base64.encode(selfSigningPriv);
|
||||
} finally {
|
||||
selfSigning.free();
|
||||
}
|
||||
}
|
||||
if (setupUserSigningKey) {
|
||||
final userSigning = olm.PkSigning();
|
||||
try {
|
||||
final userSigningPriv = userSigning.generate_seed();
|
||||
final userSigningPub = userSigning.init_with_seed(userSigningPriv);
|
||||
final json = <String, dynamic>{
|
||||
'user_id': userID,
|
||||
'usage': ['user_signing'],
|
||||
'keys': <String, dynamic>{
|
||||
'ed25519:$userSigningPub': userSigningPub,
|
||||
},
|
||||
};
|
||||
final signature = _sign(json);
|
||||
json['signatures'] = <String, dynamic>{
|
||||
userID: <String, dynamic>{
|
||||
'ed25519:$masterPub': signature,
|
||||
},
|
||||
};
|
||||
userSigningKey = MatrixCrossSigningKey.fromJson(json);
|
||||
secretsToStore[EventTypes.CrossSigningUserSigning] =
|
||||
base64.encode(userSigningPriv);
|
||||
} finally {
|
||||
userSigning.free();
|
||||
}
|
||||
}
|
||||
// upload the keys!
|
||||
state = BootstrapState.loading;
|
||||
Logs().v('Upload device signing keys.');
|
||||
await client.uiaRequestBackground(
|
||||
(AuthenticationData? auth) => client.uploadCrossSigningKeys(
|
||||
masterKey: masterKey,
|
||||
selfSigningKey: selfSigningKey,
|
||||
userSigningKey: userSigningKey,
|
||||
auth: auth,
|
||||
));
|
||||
Logs().v('Device signing keys have been uploaded.');
|
||||
// aaaand set the SSSS secrets
|
||||
final futures = <Future<void>>[];
|
||||
if (masterKey != null) {
|
||||
futures.add(
|
||||
client.onSync.stream
|
||||
.firstWhere((syncUpdate) =>
|
||||
masterKey?.publicKey != null &&
|
||||
client.userDeviceKeys[client.userID]?.masterKey?.ed25519Key ==
|
||||
masterKey?.publicKey)
|
||||
.then((_) => Logs().v('New Master Key was created')),
|
||||
);
|
||||
}
|
||||
for (final entry in secretsToStore.entries) {
|
||||
futures.add(
|
||||
client.onSync.stream
|
||||
.firstWhere((syncUpdate) =>
|
||||
syncUpdate.accountData != null &&
|
||||
syncUpdate.accountData!
|
||||
.any((accountData) => accountData.type == entry.key))
|
||||
.then((_) =>
|
||||
Logs().v('New Key with type ${entry.key} was created')),
|
||||
);
|
||||
Logs().v('Store new SSSS key ${entry.key}...');
|
||||
await newSsssKey?.store(entry.key, entry.value);
|
||||
}
|
||||
Logs().v(
|
||||
'Wait for MasterKey and ${secretsToStore.entries.length} keys to be created');
|
||||
await Future.wait<void>(futures);
|
||||
final keysToSign = <SignableKey>[];
|
||||
if (masterKey != null) {
|
||||
if (client.userDeviceKeys[client.userID]?.masterKey?.ed25519Key !=
|
||||
masterKey.publicKey) {
|
||||
throw BootstrapBadStateException(
|
||||
'ERROR: New master key does not match up!');
|
||||
}
|
||||
Logs().v('Set own master key to verified...');
|
||||
await client.userDeviceKeys[client.userID]!.masterKey!
|
||||
.setVerified(true, false);
|
||||
keysToSign.add(client.userDeviceKeys[client.userID]!.masterKey!);
|
||||
}
|
||||
if (selfSigningKey != null) {
|
||||
keysToSign.add(
|
||||
client.userDeviceKeys[client.userID]!.deviceKeys[client.deviceID]!);
|
||||
}
|
||||
Logs().v('Sign ourself...');
|
||||
await encryption.crossSigning.sign(keysToSign);
|
||||
} catch (e, s) {
|
||||
Logs().e('[Bootstrapping] Error setting up cross signing', e, s);
|
||||
state = BootstrapState.error;
|
||||
return;
|
||||
}
|
||||
|
||||
checkOnlineKeyBackup();
|
||||
}
|
||||
|
||||
void checkOnlineKeyBackup() {
|
||||
// check if we have online key backup set up
|
||||
if (encryption.keyManager.enabled) {
|
||||
state = BootstrapState.askWipeOnlineKeyBackup;
|
||||
return;
|
||||
}
|
||||
state = BootstrapState.askSetupOnlineKeyBackup;
|
||||
}
|
||||
|
||||
void wipeOnlineKeyBackup(bool wipe) {
|
||||
if (state != BootstrapState.askWipeOnlineKeyBackup) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (wipe) {
|
||||
state = BootstrapState.askSetupOnlineKeyBackup;
|
||||
} else {
|
||||
state = BootstrapState.done;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> askSetupOnlineKeyBackup(bool setup) async {
|
||||
if (state != BootstrapState.askSetupOnlineKeyBackup) {
|
||||
throw BootstrapBadStateException();
|
||||
}
|
||||
if (!setup) {
|
||||
state = BootstrapState.done;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final keyObj = olm.PkDecryption();
|
||||
String pubKey;
|
||||
Uint8List privKey;
|
||||
try {
|
||||
pubKey = keyObj.generate_key();
|
||||
privKey = keyObj.get_private_key();
|
||||
} finally {
|
||||
keyObj.free();
|
||||
}
|
||||
Logs().v('Create the new backup version...');
|
||||
await client.postRoomKeysVersion(
|
||||
BackupAlgorithm.mMegolmBackupV1Curve25519AesSha2,
|
||||
<String, dynamic>{
|
||||
'public_key': pubKey,
|
||||
},
|
||||
);
|
||||
Logs().v('Store the secret...');
|
||||
await newSsssKey?.store(megolmKey, base64.encode(privKey));
|
||||
Logs().v(
|
||||
'And finally set all megolm keys as needing to be uploaded again...');
|
||||
await client.database?.markInboundGroupSessionsAsNeedingUpload();
|
||||
} catch (e, s) {
|
||||
Logs().e('[Bootstrapping] Error setting up online key backup', e, s);
|
||||
state = BootstrapState.error;
|
||||
encryption.client.onEncryptionError.add(
|
||||
SdkError(exception: e, stackTrace: s),
|
||||
);
|
||||
return;
|
||||
}
|
||||
state = BootstrapState.done;
|
||||
}
|
||||
|
||||
set state(BootstrapState newState) {
|
||||
Logs().v('BootstrapState: $newState');
|
||||
if (state != BootstrapState.error) {
|
||||
_state = newState;
|
||||
}
|
||||
|
||||
onUpdate?.call();
|
||||
}
|
||||
}
|
||||
|
||||
class BootstrapBadStateException implements Exception {
|
||||
String cause;
|
||||
BootstrapBadStateException([this.cause = 'Bad state']);
|
||||
|
||||
@override
|
||||
String toString() => 'BootstrapBadStateException: $cause';
|
||||
}
|
||||
50
lib/encryption/utils/json_signature_check_extension.dart
Normal file
50
lib/encryption/utils/json_signature_check_extension.dart
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 'package:canonical_json/canonical_json.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
extension JsonSignatureCheckExtension on Map<String, dynamic> {
|
||||
/// Checks the signature of a signed json object.
|
||||
bool checkJsonSignature(String key, String userId, String deviceId) {
|
||||
final signatures = this['signatures'];
|
||||
if (signatures == null ||
|
||||
!(signatures is Map<String, dynamic>) ||
|
||||
!signatures.containsKey(userId)) return false;
|
||||
remove('unsigned');
|
||||
remove('signatures');
|
||||
if (!signatures[userId].containsKey('ed25519:$deviceId')) return false;
|
||||
final String signature = signatures[userId]['ed25519:$deviceId'];
|
||||
final canonical = canonicalJson.encode(this);
|
||||
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;
|
||||
}
|
||||
}
|
||||
1242
lib/encryption/utils/key_verification.dart
Normal file
1242
lib/encryption/utils/key_verification.dart
Normal file
File diff suppressed because it is too large
Load diff
61
lib/encryption/utils/olm_session.dart
Normal file
61
lib/encryption/utils/olm_session.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
class OlmSession {
|
||||
String identityKey;
|
||||
String? sessionId;
|
||||
olm.Session? session;
|
||||
DateTime? lastReceived;
|
||||
final String key;
|
||||
String? get pickledSession => session?.pickle(key);
|
||||
|
||||
bool get isValid => session != null;
|
||||
|
||||
OlmSession({
|
||||
required this.key,
|
||||
required this.identityKey,
|
||||
required this.sessionId,
|
||||
required this.session,
|
||||
required this.lastReceived,
|
||||
});
|
||||
|
||||
OlmSession.fromJson(Map<String, dynamic> dbEntry, String key)
|
||||
: key = key,
|
||||
identityKey = dbEntry['identity_key'] ?? '' {
|
||||
session = olm.Session();
|
||||
try {
|
||||
session!.unpickle(key, dbEntry['pickle']);
|
||||
sessionId = dbEntry['session_id'];
|
||||
lastReceived =
|
||||
DateTime.fromMillisecondsSinceEpoch(dbEntry['last_received'] ?? 0);
|
||||
assert(sessionId == session!.session_id());
|
||||
} catch (e, s) {
|
||||
Logs().e('[LibOlm] Could not unpickle olm session', e, s);
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
session?.free();
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
72
lib/encryption/utils/outbound_group_session.dart
Normal file
72
lib/encryption/utils/outbound_group_session.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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:olm/olm.dart' as olm;
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
class OutboundGroupSession {
|
||||
/// The devices is a map from user id to device id to if the device is blocked.
|
||||
/// This way we can easily know if a new user is added, leaves, a new devices is added, and,
|
||||
/// very importantly, if we block a device. These are all important for determining if/when
|
||||
/// an outbound session needs to be rotated.
|
||||
Map<String, Map<String, bool>> devices = {};
|
||||
// Default to a date, that would get this session rotated in any case to make handling easier
|
||||
DateTime creationTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
olm.OutboundGroupSession? outboundGroupSession;
|
||||
int? get sentMessages => outboundGroupSession?.message_index();
|
||||
bool get isValid => outboundGroupSession != null;
|
||||
final String key;
|
||||
|
||||
OutboundGroupSession(
|
||||
{required this.devices,
|
||||
required this.creationTime,
|
||||
required this.outboundGroupSession,
|
||||
required this.key});
|
||||
|
||||
OutboundGroupSession.fromJson(Map<String, dynamic> dbEntry, String key)
|
||||
: key = key {
|
||||
try {
|
||||
for (final entry in json.decode(dbEntry['device_ids']).entries) {
|
||||
devices[entry.key] = Map<String, bool>.from(entry.value);
|
||||
}
|
||||
} catch (e) {
|
||||
// devices is bad (old data), so just not use this session
|
||||
Logs().i(
|
||||
'[OutboundGroupSession] Session in database is old, not using it. ' +
|
||||
e.toString());
|
||||
return;
|
||||
}
|
||||
outboundGroupSession = olm.OutboundGroupSession();
|
||||
try {
|
||||
outboundGroupSession!.unpickle(key, dbEntry['pickle']);
|
||||
creationTime =
|
||||
DateTime.fromMillisecondsSinceEpoch(dbEntry['creation_time']);
|
||||
} catch (e, s) {
|
||||
dispose();
|
||||
Logs().e('[LibOlm] Unable to unpickle outboundGroupSession', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
outboundGroupSession?.free();
|
||||
outboundGroupSession = null;
|
||||
}
|
||||
}
|
||||
115
lib/encryption/utils/session_key.dart
Normal file
115
lib/encryption/utils/session_key.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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 'package:matrix/encryption/utils/stored_inbound_group_session.dart';
|
||||
import 'package:matrix_api_lite/src/utils/filter_map_extension.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
class SessionKey {
|
||||
/// The raw json content of the key
|
||||
Map<String, dynamic> content = <String, dynamic>{};
|
||||
|
||||
/// Map of stringified-index to event id, so that we can detect replay attacks
|
||||
Map<String, String> indexes;
|
||||
|
||||
/// Map of userId to map of deviceId to index, that we know that device receivied, e.g. sending it ourself.
|
||||
/// Used for automatically answering key requests
|
||||
Map<String, Map<String, int>> allowedAtIndex;
|
||||
|
||||
/// Underlying olm [InboundGroupSession] object
|
||||
olm.InboundGroupSession? inboundGroupSession;
|
||||
|
||||
/// Key for libolm pickle / unpickle
|
||||
final String key;
|
||||
|
||||
/// Forwarding keychain
|
||||
List<String> get forwardingCurve25519KeyChain =>
|
||||
(content['forwarding_curve25519_key_chain'] != null
|
||||
? List<String>.from(content['forwarding_curve25519_key_chain'])
|
||||
: null) ??
|
||||
<String>[];
|
||||
|
||||
/// Claimed keys of the original sender
|
||||
late Map<String, String> senderClaimedKeys;
|
||||
|
||||
/// Sender curve25519 key
|
||||
String senderKey;
|
||||
|
||||
/// Is this session valid?
|
||||
bool get isValid => inboundGroupSession != null;
|
||||
|
||||
/// roomId for this session
|
||||
String roomId;
|
||||
|
||||
/// Id of this session
|
||||
String sessionId;
|
||||
|
||||
SessionKey(
|
||||
{required this.content,
|
||||
required this.inboundGroupSession,
|
||||
required this.key,
|
||||
Map<String, String>? indexes,
|
||||
Map<String, Map<String, int>>? allowedAtIndex,
|
||||
required this.roomId,
|
||||
required this.sessionId,
|
||||
required this.senderKey,
|
||||
required this.senderClaimedKeys})
|
||||
: indexes = indexes ?? <String, String>{},
|
||||
allowedAtIndex = allowedAtIndex ?? <String, Map<String, int>>{};
|
||||
|
||||
SessionKey.fromDb(StoredInboundGroupSession dbEntry, String key)
|
||||
: key = key,
|
||||
content = Event.getMapFromPayload(dbEntry.content),
|
||||
indexes = Event.getMapFromPayload(dbEntry.indexes)
|
||||
.catchMap((k, v) => MapEntry<String, String>(k, v)),
|
||||
allowedAtIndex = Event.getMapFromPayload(dbEntry.allowedAtIndex)
|
||||
.catchMap((k, v) => MapEntry(k, Map<String, int>.from(v))),
|
||||
roomId = dbEntry.roomId,
|
||||
sessionId = dbEntry.sessionId,
|
||||
senderKey = dbEntry.senderKey,
|
||||
inboundGroupSession = olm.InboundGroupSession() {
|
||||
final parsedSenderClaimedKeys =
|
||||
Event.getMapFromPayload(dbEntry.senderClaimedKeys)
|
||||
.catchMap((k, v) => MapEntry<String, String>(k, v));
|
||||
// we need to try...catch as the map used to be <String, int> and that will throw an error.
|
||||
senderClaimedKeys = (parsedSenderClaimedKeys.isNotEmpty)
|
||||
? parsedSenderClaimedKeys
|
||||
: (content['sender_claimed_keys'] is Map
|
||||
? content['sender_claimed_keys']
|
||||
.catchMap((k, v) => MapEntry<String, String>(k, v))
|
||||
: (content['sender_claimed_ed25519_key'] is String
|
||||
? <String, String>{
|
||||
'ed25519': content['sender_claimed_ed25519_key']
|
||||
}
|
||||
: <String, String>{}));
|
||||
|
||||
try {
|
||||
inboundGroupSession!.unpickle(key, dbEntry.pickle);
|
||||
} catch (e, s) {
|
||||
dispose();
|
||||
Logs().e('[LibOlm] Unable to unpickle inboundGroupSession', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
inboundGroupSession?.free();
|
||||
inboundGroupSession = null;
|
||||
}
|
||||
}
|
||||
40
lib/encryption/utils/ssss_cache.dart
Normal file
40
lib/encryption/utils/ssss_cache.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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/>.
|
||||
*/
|
||||
|
||||
class SSSSCache {
|
||||
final String? type;
|
||||
final String? keyId;
|
||||
final String? ciphertext;
|
||||
final String? content;
|
||||
|
||||
const SSSSCache({this.type, this.keyId, this.ciphertext, this.content});
|
||||
|
||||
factory SSSSCache.fromJson(Map<String, dynamic> json) => SSSSCache(
|
||||
type: json['type'],
|
||||
keyId: json['key_id'],
|
||||
ciphertext: json['ciphertext'],
|
||||
content: json['content'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'type': type,
|
||||
'key_id': keyId,
|
||||
'ciphertext': ciphertext,
|
||||
'content': content,
|
||||
};
|
||||
}
|
||||
66
lib/encryption/utils/stored_inbound_group_session.dart
Normal file
66
lib/encryption/utils/stored_inbound_group_session.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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/>.
|
||||
*/
|
||||
|
||||
class StoredInboundGroupSession {
|
||||
final String roomId;
|
||||
final String sessionId;
|
||||
final String pickle;
|
||||
final String content;
|
||||
final String indexes;
|
||||
final String allowedAtIndex;
|
||||
final bool uploaded;
|
||||
final String senderKey;
|
||||
final String senderClaimedKeys;
|
||||
|
||||
StoredInboundGroupSession({
|
||||
required this.roomId,
|
||||
required this.sessionId,
|
||||
required this.pickle,
|
||||
required this.content,
|
||||
required this.indexes,
|
||||
required this.allowedAtIndex,
|
||||
required this.uploaded,
|
||||
required this.senderKey,
|
||||
required this.senderClaimedKeys,
|
||||
});
|
||||
|
||||
factory StoredInboundGroupSession.fromJson(Map<String, dynamic> json) =>
|
||||
StoredInboundGroupSession(
|
||||
roomId: json['room_id'],
|
||||
sessionId: json['session_id'],
|
||||
pickle: json['pickle'],
|
||||
content: json['content'],
|
||||
indexes: json['indexes'],
|
||||
allowedAtIndex: json['allowed_at_index'],
|
||||
uploaded: json['uploaded'],
|
||||
senderKey: json['sender_key'],
|
||||
senderClaimedKeys: json['sender_claimed_keys'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'room_id': roomId,
|
||||
'session_id': sessionId,
|
||||
'pickle': pickle,
|
||||
'content': content,
|
||||
'indexes': indexes,
|
||||
'allowed_at_index': allowedAtIndex,
|
||||
'uploaded': uploaded,
|
||||
'sender_key': senderKey,
|
||||
'sender_claimed_keys': senderClaimedKeys,
|
||||
};
|
||||
}
|
||||
49
lib/matrix.dart
Normal file
49
lib/matrix.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/// Matrix SDK written in pure Dart.
|
||||
library matrix;
|
||||
|
||||
export 'package:matrix_api_lite/matrix_api_lite.dart';
|
||||
|
||||
export 'src/client.dart';
|
||||
export 'src/database/database_api.dart';
|
||||
export 'src/database/hive_database.dart';
|
||||
export 'src/database/fluffybox_database.dart';
|
||||
export 'src/event.dart';
|
||||
export 'src/event_status.dart';
|
||||
export 'src/voip.dart';
|
||||
export 'src/voip_content.dart';
|
||||
export 'src/room.dart';
|
||||
export 'src/timeline.dart';
|
||||
export 'src/user.dart';
|
||||
export 'src/utils/commands_extension.dart';
|
||||
export 'src/utils/crypto/encrypted_file.dart';
|
||||
export 'src/utils/device_keys_list.dart';
|
||||
export 'src/utils/event_update.dart';
|
||||
export 'src/utils/http_timeout.dart';
|
||||
export 'src/utils/image_pack_extension.dart';
|
||||
export 'src/utils/matrix_file.dart';
|
||||
export 'src/utils/matrix_id_string_extension.dart';
|
||||
export 'src/utils/matrix_localizations.dart';
|
||||
export 'src/utils/receipt.dart';
|
||||
export 'src/utils/sync_update_extension.dart';
|
||||
export 'src/utils/to_device_event.dart';
|
||||
export 'src/utils/uia_request.dart';
|
||||
export 'src/utils/uri_extension.dart';
|
||||
export 'src/voip_content.dart';
|
||||
2542
lib/src/client.dart
Normal file
2542
lib/src/client.dart
Normal file
File diff suppressed because it is too large
Load diff
309
lib/src/database/database_api.dart
Normal file
309
lib/src/database/database_api.dart
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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:typed_data';
|
||||
|
||||
import 'package:matrix/encryption/utils/olm_session.dart';
|
||||
import 'package:matrix/encryption/utils/outbound_group_session.dart';
|
||||
import 'package:matrix/encryption/utils/ssss_cache.dart';
|
||||
import 'package:matrix/encryption/utils/stored_inbound_group_session.dart';
|
||||
import 'package:matrix/src/utils/queued_to_device_event.dart';
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
abstract class DatabaseApi {
|
||||
int get maxFileSize => 1 * 1024 * 1024;
|
||||
bool get supportsFileStoring => false;
|
||||
Future<Map<String, dynamic>?> getClient(String name);
|
||||
|
||||
Future updateClient(
|
||||
String homeserverUrl,
|
||||
String token,
|
||||
String userId,
|
||||
String? deviceId,
|
||||
String? deviceName,
|
||||
String? prevBatch,
|
||||
String? olmAccount,
|
||||
);
|
||||
|
||||
Future insertClient(
|
||||
String name,
|
||||
String homeserverUrl,
|
||||
String token,
|
||||
String userId,
|
||||
String? deviceId,
|
||||
String? deviceName,
|
||||
String? prevBatch,
|
||||
String? olmAccount,
|
||||
);
|
||||
|
||||
Future<List<Room>> getRoomList(Client client);
|
||||
|
||||
Future<Map<String, BasicEvent>> getAccountData();
|
||||
|
||||
/// Stores a RoomUpdate object in the database. Must be called inside of
|
||||
/// [transaction].
|
||||
Future<void> storeRoomUpdate(
|
||||
String roomId, SyncRoomUpdate roomUpdate, Client client);
|
||||
|
||||
/// Stores an EventUpdate object in the database. Must be called inside of
|
||||
/// [transaction].
|
||||
Future<void> storeEventUpdate(EventUpdate eventUpdate, Client client);
|
||||
|
||||
Future<Event?> getEventById(String eventId, Room room);
|
||||
|
||||
Future<void> forgetRoom(String roomId);
|
||||
|
||||
Future<void> clearCache();
|
||||
|
||||
Future<void> clear();
|
||||
|
||||
Future<User?> getUser(String userId, Room room);
|
||||
|
||||
Future<List<User>> getUsers(Room room);
|
||||
|
||||
Future<List<Event>> getEventList(
|
||||
Room room, {
|
||||
int start = 0,
|
||||
int limit,
|
||||
});
|
||||
|
||||
Future<Uint8List?> getFile(Uri mxcUri);
|
||||
|
||||
Future storeFile(Uri mxcUri, Uint8List bytes, int time);
|
||||
|
||||
Future storeSyncFilterId(
|
||||
String syncFilterId,
|
||||
);
|
||||
|
||||
Future storeAccountData(String type, String content);
|
||||
|
||||
Future<Map<String, DeviceKeysList>> getUserDeviceKeys(Client client);
|
||||
|
||||
Future<SSSSCache?> getSSSSCache(String type);
|
||||
|
||||
Future<OutboundGroupSession?> getOutboundGroupSession(
|
||||
String roomId,
|
||||
String userId,
|
||||
);
|
||||
|
||||
Future<List<StoredInboundGroupSession>> getAllInboundGroupSessions();
|
||||
|
||||
Future<StoredInboundGroupSession?> getInboundGroupSession(
|
||||
String roomId,
|
||||
String sessionId,
|
||||
);
|
||||
|
||||
Future updateInboundGroupSessionIndexes(
|
||||
String indexes,
|
||||
String roomId,
|
||||
String sessionId,
|
||||
);
|
||||
|
||||
Future storeInboundGroupSession(
|
||||
String roomId,
|
||||
String sessionId,
|
||||
String pickle,
|
||||
String content,
|
||||
String indexes,
|
||||
String allowedAtIndex,
|
||||
String senderKey,
|
||||
String senderClaimedKey,
|
||||
);
|
||||
|
||||
Future markInboundGroupSessionAsUploaded(
|
||||
String roomId,
|
||||
String sessionId,
|
||||
);
|
||||
|
||||
Future updateInboundGroupSessionAllowedAtIndex(
|
||||
String allowedAtIndex,
|
||||
String roomId,
|
||||
String sessionId,
|
||||
);
|
||||
|
||||
Future removeOutboundGroupSession(String roomId);
|
||||
|
||||
Future storeOutboundGroupSession(
|
||||
String roomId,
|
||||
String pickle,
|
||||
String deviceIds,
|
||||
int creationTime,
|
||||
);
|
||||
|
||||
Future updateClientKeys(
|
||||
String olmAccount,
|
||||
);
|
||||
|
||||
Future storeOlmSession(
|
||||
String identitiyKey,
|
||||
String sessionId,
|
||||
String pickle,
|
||||
int lastReceived,
|
||||
);
|
||||
|
||||
Future setLastActiveUserDeviceKey(
|
||||
int lastActive,
|
||||
String userId,
|
||||
String deviceId,
|
||||
);
|
||||
|
||||
Future setLastSentMessageUserDeviceKey(
|
||||
String lastSentMessage,
|
||||
String userId,
|
||||
String deviceId,
|
||||
);
|
||||
|
||||
Future clearSSSSCache();
|
||||
|
||||
Future storeSSSSCache(
|
||||
String type,
|
||||
String keyId,
|
||||
String ciphertext,
|
||||
String content,
|
||||
);
|
||||
|
||||
Future markInboundGroupSessionsAsNeedingUpload();
|
||||
|
||||
Future storePrevBatch(
|
||||
String prevBatch,
|
||||
);
|
||||
|
||||
Future deleteOldFiles(int savedAt);
|
||||
|
||||
Future storeUserDeviceKeysInfo(
|
||||
String userId,
|
||||
bool outdated,
|
||||
);
|
||||
|
||||
Future storeUserDeviceKey(
|
||||
String userId,
|
||||
String deviceId,
|
||||
String content,
|
||||
bool verified,
|
||||
bool blocked,
|
||||
int lastActive,
|
||||
);
|
||||
|
||||
Future removeUserDeviceKey(
|
||||
String userId,
|
||||
String deviceId,
|
||||
);
|
||||
|
||||
Future removeUserCrossSigningKey(
|
||||
String userId,
|
||||
String publicKey,
|
||||
);
|
||||
|
||||
Future storeUserCrossSigningKey(
|
||||
String userId,
|
||||
String publicKey,
|
||||
String content,
|
||||
bool verified,
|
||||
bool blocked,
|
||||
);
|
||||
|
||||
Future deleteFromToDeviceQueue(int id);
|
||||
|
||||
Future removeEvent(String eventId, String roomId);
|
||||
|
||||
Future updateRoomSortOrder(
|
||||
double oldestSortOrder,
|
||||
double newestSortOrder,
|
||||
String roomId,
|
||||
);
|
||||
|
||||
Future setRoomPrevBatch(
|
||||
String prevBatch,
|
||||
String roomId,
|
||||
Client client,
|
||||
);
|
||||
|
||||
Future resetNotificationCount(String roomId);
|
||||
|
||||
Future setVerifiedUserCrossSigningKey(
|
||||
bool verified,
|
||||
String userId,
|
||||
String publicKey,
|
||||
);
|
||||
|
||||
Future setBlockedUserCrossSigningKey(
|
||||
bool blocked,
|
||||
String userId,
|
||||
String publicKey,
|
||||
);
|
||||
|
||||
Future setVerifiedUserDeviceKey(
|
||||
bool verified,
|
||||
String userId,
|
||||
String deviceId,
|
||||
);
|
||||
|
||||
Future setBlockedUserDeviceKey(
|
||||
bool blocked,
|
||||
String userId,
|
||||
String deviceId,
|
||||
);
|
||||
|
||||
Future<List<Event>> getUnimportantRoomEventStatesForRoom(
|
||||
List<String> events,
|
||||
Room room,
|
||||
);
|
||||
|
||||
Future<List<OlmSession>> getOlmSessions(
|
||||
String identityKey,
|
||||
String userId,
|
||||
);
|
||||
|
||||
Future<Map<String, Map>> getAllOlmSessions();
|
||||
|
||||
Future<List<OlmSession>> getOlmSessionsForDevices(
|
||||
List<String> identityKeys,
|
||||
String userId,
|
||||
);
|
||||
|
||||
Future<List<QueuedToDeviceEvent>> getToDeviceEventQueue();
|
||||
|
||||
/// Please do `jsonEncode(content)` in your code to stay compatible with
|
||||
/// auto generated methods here.
|
||||
Future insertIntoToDeviceQueue(
|
||||
String type,
|
||||
String txnId,
|
||||
String content,
|
||||
);
|
||||
|
||||
Future<List<String>> getLastSentMessageUserDeviceKey(
|
||||
String userId,
|
||||
String deviceId,
|
||||
);
|
||||
|
||||
Future<List<StoredInboundGroupSession>> getInboundGroupSessionsToUpload();
|
||||
|
||||
Future<void> addSeenDeviceId(
|
||||
String userId, String deviceId, String publicKeys);
|
||||
|
||||
Future<void> addSeenPublicKey(String publicKey, String deviceId);
|
||||
|
||||
Future<String?> deviceIdSeen(userId, deviceId);
|
||||
|
||||
Future<String?> publicKeySeen(String publicKey);
|
||||
|
||||
Future<dynamic> close();
|
||||
|
||||
Future<T> transaction<T>(Future<T> Function() action);
|
||||
}
|
||||
1478
lib/src/database/fluffybox_database.dart
Normal file
1478
lib/src/database/fluffybox_database.dart
Normal file
File diff suppressed because it is too large
Load diff
1433
lib/src/database/hive_database.dart
Normal file
1433
lib/src/database/hive_database.dart
Normal file
File diff suppressed because it is too large
Load diff
792
lib/src/event.dart
Normal file
792
lib/src/event.dart
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
/*
|
||||
* 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 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../matrix.dart';
|
||||
import 'utils/event_localizations.dart';
|
||||
import 'utils/html_to_text.dart';
|
||||
|
||||
abstract class RelationshipTypes {
|
||||
static const String reply = 'm.in_reply_to';
|
||||
static const String edit = 'm.replace';
|
||||
static const String reaction = 'm.annotation';
|
||||
}
|
||||
|
||||
/// All data exchanged over Matrix is expressed as an "event". Typically each client action (e.g. sending a message) correlates with exactly one event.
|
||||
class Event extends MatrixEvent {
|
||||
User get sender => room.getUserByMXIDSync(senderId);
|
||||
|
||||
@Deprecated('Use [originServerTs] instead')
|
||||
DateTime get time => originServerTs;
|
||||
|
||||
@Deprecated('Use [type] instead')
|
||||
String get typeKey => type;
|
||||
|
||||
@Deprecated('Use [sender.calcDisplayname()] instead')
|
||||
String? get senderName => sender.calcDisplayname();
|
||||
|
||||
/// The room this event belongs to. May be null.
|
||||
final Room room;
|
||||
|
||||
/// The status of this event.
|
||||
EventStatus status;
|
||||
|
||||
static const EventStatus defaultStatus = EventStatus.synced;
|
||||
|
||||
/// Optional. The event that redacted this event, if any. Otherwise null.
|
||||
Event? get redactedBecause {
|
||||
final redacted_because = unsigned?['redacted_because'];
|
||||
final room = this.room;
|
||||
return (redacted_because is Map<String, dynamic>)
|
||||
? Event.fromJson(redacted_because, room)
|
||||
: null;
|
||||
}
|
||||
|
||||
bool get redacted => redactedBecause != null;
|
||||
|
||||
User? get stateKeyUser => room.getUserByMXIDSync(stateKey!);
|
||||
|
||||
Event({
|
||||
this.status = defaultStatus,
|
||||
required Map<String, dynamic> content,
|
||||
required String type,
|
||||
required String eventId,
|
||||
required String senderId,
|
||||
required DateTime originServerTs,
|
||||
Map<String, dynamic>? unsigned,
|
||||
Map<String, dynamic>? prevContent,
|
||||
String? stateKey,
|
||||
required this.room,
|
||||
}) : super(
|
||||
content: content,
|
||||
type: type,
|
||||
eventId: eventId,
|
||||
senderId: senderId,
|
||||
originServerTs: originServerTs,
|
||||
roomId: room.id,
|
||||
) {
|
||||
this.eventId = eventId;
|
||||
this.unsigned = unsigned;
|
||||
// synapse unfortunately isn't following the spec and tosses the prev_content
|
||||
// into the unsigned block.
|
||||
// Currently we are facing a very strange bug in web which is impossible to debug.
|
||||
// It may be because of this line so we put this in try-catch until we can fix it.
|
||||
try {
|
||||
this.prevContent = (prevContent != null && prevContent.isNotEmpty)
|
||||
? prevContent
|
||||
: (unsigned != null &&
|
||||
unsigned.containsKey('prev_content') &&
|
||||
unsigned['prev_content'] is Map)
|
||||
? unsigned['prev_content']
|
||||
: null;
|
||||
} catch (_) {
|
||||
// A strange bug in dart web makes this crash
|
||||
}
|
||||
this.stateKey = stateKey;
|
||||
|
||||
// Mark event as failed to send if status is `sending` and event is older
|
||||
// than the timeout. This should not happen with the deprecated Moor
|
||||
// database!
|
||||
if (status.isSending && room.client.database != null) {
|
||||
// Age of this event in milliseconds
|
||||
final age = DateTime.now().millisecondsSinceEpoch -
|
||||
originServerTs.millisecondsSinceEpoch;
|
||||
|
||||
final room = this.room;
|
||||
if (age > room.client.sendMessageTimeoutSeconds * 1000) {
|
||||
// Update this event in database and open timelines
|
||||
final json = toJson();
|
||||
json['unsigned'] ??= <String, dynamic>{};
|
||||
json['unsigned'][messageSendingStatusKey] = EventStatus.error.intValue;
|
||||
room.client.handleSync(
|
||||
SyncUpdate(
|
||||
nextBatch: '',
|
||||
rooms: RoomsUpdate(
|
||||
join: {
|
||||
room.id: JoinedRoomUpdate(
|
||||
timeline: TimelineUpdate(
|
||||
events: [MatrixEvent.fromJson(json)],
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic> getMapFromPayload(dynamic payload) {
|
||||
if (payload is String) {
|
||||
try {
|
||||
return json.decode(payload);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (payload is Map<String, dynamic>) return payload;
|
||||
return {};
|
||||
}
|
||||
|
||||
factory Event.fromMatrixEvent(
|
||||
MatrixEvent matrixEvent,
|
||||
Room room, {
|
||||
EventStatus status = defaultStatus,
|
||||
}) =>
|
||||
Event(
|
||||
status: status,
|
||||
content: matrixEvent.content,
|
||||
type: matrixEvent.type,
|
||||
eventId: matrixEvent.eventId,
|
||||
senderId: matrixEvent.senderId,
|
||||
originServerTs: matrixEvent.originServerTs,
|
||||
unsigned: matrixEvent.unsigned,
|
||||
prevContent: matrixEvent.prevContent,
|
||||
stateKey: matrixEvent.stateKey,
|
||||
room: room,
|
||||
);
|
||||
|
||||
/// Get a State event from a table row or from the event stream.
|
||||
factory Event.fromJson(
|
||||
Map<String, dynamic> jsonPayload,
|
||||
Room room,
|
||||
) {
|
||||
final content = Event.getMapFromPayload(jsonPayload['content']);
|
||||
final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']);
|
||||
final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']);
|
||||
return Event(
|
||||
status: eventStatusFromInt(jsonPayload['status'] ??
|
||||
unsigned[messageSendingStatusKey] ??
|
||||
defaultStatus.intValue),
|
||||
stateKey: jsonPayload['state_key'],
|
||||
prevContent: prevContent,
|
||||
content: content,
|
||||
type: jsonPayload['type'],
|
||||
eventId: jsonPayload['event_id'] ?? '',
|
||||
senderId: jsonPayload['sender'],
|
||||
originServerTs: jsonPayload.containsKey('origin_server_ts')
|
||||
? DateTime.fromMillisecondsSinceEpoch(jsonPayload['origin_server_ts'])
|
||||
: DateTime.now(),
|
||||
unsigned: unsigned,
|
||||
room: room,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final data = <String, dynamic>{};
|
||||
if (stateKey != null) data['state_key'] = stateKey;
|
||||
if (prevContent?.isNotEmpty == true) {
|
||||
data['prev_content'] = prevContent;
|
||||
}
|
||||
data['content'] = content;
|
||||
data['type'] = type;
|
||||
data['event_id'] = eventId;
|
||||
data['room_id'] = roomId;
|
||||
data['sender'] = senderId;
|
||||
data['origin_server_ts'] = originServerTs.millisecondsSinceEpoch;
|
||||
if (unsigned?.isNotEmpty == true) {
|
||||
data['unsigned'] = unsigned;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
User get asUser => User.fromState(
|
||||
// state key should always be set for member events
|
||||
stateKey: stateKey!,
|
||||
prevContent: prevContent,
|
||||
content: content,
|
||||
typeKey: type,
|
||||
eventId: eventId,
|
||||
roomId: roomId,
|
||||
senderId: senderId,
|
||||
originServerTs: originServerTs,
|
||||
unsigned: unsigned,
|
||||
room: room);
|
||||
|
||||
String get messageType => type == EventTypes.Sticker
|
||||
? MessageTypes.Sticker
|
||||
: (content['msgtype'] is String ? content['msgtype'] : MessageTypes.Text);
|
||||
|
||||
void setRedactionEvent(Event redactedBecause) {
|
||||
unsigned = {
|
||||
'redacted_because': redactedBecause.toJson(),
|
||||
};
|
||||
prevContent = null;
|
||||
final contentKeyWhiteList = <String>[];
|
||||
switch (type) {
|
||||
case EventTypes.RoomMember:
|
||||
contentKeyWhiteList.add('membership');
|
||||
break;
|
||||
case EventTypes.RoomCreate:
|
||||
contentKeyWhiteList.add('creator');
|
||||
break;
|
||||
case EventTypes.RoomJoinRules:
|
||||
contentKeyWhiteList.add('join_rule');
|
||||
break;
|
||||
case EventTypes.RoomPowerLevels:
|
||||
contentKeyWhiteList.add('ban');
|
||||
contentKeyWhiteList.add('events');
|
||||
contentKeyWhiteList.add('events_default');
|
||||
contentKeyWhiteList.add('kick');
|
||||
contentKeyWhiteList.add('redact');
|
||||
contentKeyWhiteList.add('state_default');
|
||||
contentKeyWhiteList.add('users');
|
||||
contentKeyWhiteList.add('users_default');
|
||||
break;
|
||||
case EventTypes.RoomAliases:
|
||||
contentKeyWhiteList.add('aliases');
|
||||
break;
|
||||
case EventTypes.HistoryVisibility:
|
||||
contentKeyWhiteList.add('history_visibility');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
content.removeWhere((k, v) => !contentKeyWhiteList.contains(k));
|
||||
}
|
||||
|
||||
/// Returns the body of this event if it has a body.
|
||||
String get text => content['body'] is String ? content['body'] : '';
|
||||
|
||||
/// Returns the formatted boy of this event if it has a formatted body.
|
||||
String get formattedText =>
|
||||
content['formatted_body'] is String ? content['formatted_body'] : '';
|
||||
|
||||
/// Use this to get the body.
|
||||
String get body {
|
||||
if (redacted) return 'Redacted';
|
||||
if (text != '') return text;
|
||||
if (formattedText != '') return formattedText;
|
||||
return '$type';
|
||||
}
|
||||
|
||||
/// Use this to get a plain-text representation of the event, stripping things
|
||||
/// like spoilers and thelike. Useful for plain text notifications.
|
||||
String get plaintextBody => content['format'] == 'org.matrix.custom.html'
|
||||
? HtmlToText.convert(formattedText)
|
||||
: body;
|
||||
|
||||
/// Returns a list of [Receipt] instances for this event.
|
||||
List<Receipt> get receipts {
|
||||
final room = this.room;
|
||||
final receipt = room.roomAccountData['m.receipt'];
|
||||
if (receipt == null) return [];
|
||||
return receipt.content.entries
|
||||
.where((entry) => entry.value['event_id'] == eventId)
|
||||
.map((entry) => Receipt(room.getUserByMXIDSync(entry.key),
|
||||
DateTime.fromMillisecondsSinceEpoch(entry.value['ts'])))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Removes this event if the status is [sending], [error] or [removed].
|
||||
/// This event will just be removed from the database and the timelines.
|
||||
/// Returns [false] if not removed.
|
||||
Future<bool> remove() async {
|
||||
final room = this.room;
|
||||
|
||||
if (!status.isSent) {
|
||||
await room.client.database?.removeEvent(eventId, room.id);
|
||||
|
||||
room.client.onEvent.add(EventUpdate(
|
||||
roomID: room.id,
|
||||
type: EventUpdateType.timeline,
|
||||
content: {
|
||||
'event_id': eventId,
|
||||
'status': EventStatus.removed.intValue,
|
||||
'content': {'body': 'Removed...'}
|
||||
},
|
||||
));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Try to send this event again. Only works with events of status -1.
|
||||
Future<String?> sendAgain({String? txid}) async {
|
||||
if (!status.isError) return null;
|
||||
// we do not remove the event here. It will automatically be updated
|
||||
// in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
|
||||
final newEventId = await room.sendEvent(
|
||||
content,
|
||||
txid: txid ?? unsigned?['transaction_id'] ?? eventId,
|
||||
);
|
||||
return newEventId;
|
||||
}
|
||||
|
||||
/// Whether the client is allowed to redact this event.
|
||||
bool get canRedact => senderId == room.client.userID || room.canRedact;
|
||||
|
||||
/// Redacts this event. Throws `ErrorResponse` on error.
|
||||
Future<String?> redactEvent({String? reason, String? txid}) async =>
|
||||
await room.redactEvent(eventId, reason: reason, txid: txid);
|
||||
|
||||
/// Searches for the reply event in the given timeline.
|
||||
Future<Event?> getReplyEvent(Timeline timeline) async {
|
||||
if (relationshipType != RelationshipTypes.reply) return null;
|
||||
final relationshipEventId = this.relationshipEventId;
|
||||
return relationshipEventId == null
|
||||
? null
|
||||
: await timeline.getEventById(relationshipEventId);
|
||||
}
|
||||
|
||||
/// If this event is encrypted and the decryption was not successful because
|
||||
/// the session is unknown, this requests the session key from other devices
|
||||
/// in the room. If the event is not encrypted or the decryption failed because
|
||||
/// of a different error, this throws an exception.
|
||||
Future<void> requestKey() async {
|
||||
if (type != EventTypes.Encrypted ||
|
||||
messageType != MessageTypes.BadEncrypted ||
|
||||
content['can_request_session'] != true) {
|
||||
throw ('Session key not requestable');
|
||||
}
|
||||
await room.requestSessionKey(content['session_id'], content['sender_key']);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Gets the info map of file events, or a blank map if none present
|
||||
Map get infoMap =>
|
||||
content['info'] is Map ? content['info'] : <String, dynamic>{};
|
||||
|
||||
/// Gets the thumbnail info map of file events, or a blank map if nonepresent
|
||||
Map get thumbnailInfoMap => infoMap['thumbnail_info'] is Map
|
||||
? infoMap['thumbnail_info']
|
||||
: <String, dynamic>{};
|
||||
|
||||
/// Returns if a file event has an attachment
|
||||
bool get hasAttachment => content['url'] is String || content['file'] is Map;
|
||||
|
||||
/// Returns if a file event has a thumbnail
|
||||
bool get hasThumbnail =>
|
||||
infoMap['thumbnail_url'] is String || infoMap['thumbnail_file'] is Map;
|
||||
|
||||
/// Returns if a file events attachment is encrypted
|
||||
bool get isAttachmentEncrypted => content['file'] is Map;
|
||||
|
||||
/// Returns if a file events thumbnail is encrypted
|
||||
bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map;
|
||||
|
||||
/// Gets the mimetype of the attachment of a file event, or a blank string if not present
|
||||
String get attachmentMimetype => infoMap['mimetype'] is String
|
||||
? infoMap['mimetype'].toLowerCase()
|
||||
: (content['file'] is Map && content['file']['mimetype'] is String
|
||||
? content['file']['mimetype']
|
||||
: '');
|
||||
|
||||
/// Gets the mimetype of the thumbnail of a file event, or a blank string if not present
|
||||
String get thumbnailMimetype => thumbnailInfoMap['mimetype'] is String
|
||||
? thumbnailInfoMap['mimetype'].toLowerCase()
|
||||
: (infoMap['thumbnail_file'] is Map &&
|
||||
infoMap['thumbnail_file']['mimetype'] is String
|
||||
? infoMap['thumbnail_file']['mimetype']
|
||||
: '');
|
||||
|
||||
/// Gets the underlying mxc url of an attachment of a file event, or null if not present
|
||||
Uri? get attachmentMxcUrl {
|
||||
final url = isAttachmentEncrypted ? content['file']['url'] : content['url'];
|
||||
return url is String ? Uri.tryParse(url) : null;
|
||||
}
|
||||
|
||||
/// Gets the underlying mxc url of a thumbnail of a file event, or null if not present
|
||||
Uri? get thumbnailMxcUrl {
|
||||
final url = isThumbnailEncrypted
|
||||
? infoMap['thumbnail_file']['url']
|
||||
: infoMap['thumbnail_url'];
|
||||
return url is String ? Uri.tryParse(url) : null;
|
||||
}
|
||||
|
||||
/// Gets the mxc url of an attachment/thumbnail of a file event, taking sizes into account, or null if not present
|
||||
Uri? attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) {
|
||||
if (getThumbnail &&
|
||||
infoMap['size'] is int &&
|
||||
thumbnailInfoMap['size'] is int &&
|
||||
infoMap['size'] <= thumbnailInfoMap['size']) {
|
||||
getThumbnail = false;
|
||||
}
|
||||
if (getThumbnail && !hasThumbnail) {
|
||||
getThumbnail = false;
|
||||
}
|
||||
return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl;
|
||||
}
|
||||
|
||||
// size determined from an approximate 800x800 jpeg thumbnail with method=scale
|
||||
static const _minNoThumbSize = 80 * 1024;
|
||||
|
||||
/// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
|
||||
/// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment.
|
||||
/// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method]
|
||||
/// for the respective thumbnailing properties.
|
||||
/// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k
|
||||
/// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment.
|
||||
/// [animated] says weather the thumbnail is animated
|
||||
Uri? getAttachmentUrl(
|
||||
{bool getThumbnail = false,
|
||||
bool useThumbnailMxcUrl = false,
|
||||
double width = 800.0,
|
||||
double height = 800.0,
|
||||
ThumbnailMethod method = ThumbnailMethod.scale,
|
||||
int minNoThumbSize = _minNoThumbSize,
|
||||
bool animated = false}) {
|
||||
if (![EventTypes.Message, EventTypes.Sticker].contains(type) ||
|
||||
!hasAttachment ||
|
||||
isAttachmentEncrypted) {
|
||||
return null; // can't url-thumbnail in encrypted rooms
|
||||
}
|
||||
if (useThumbnailMxcUrl && !hasThumbnail) {
|
||||
return null; // can't fetch from thumbnail
|
||||
}
|
||||
final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
|
||||
final thisMxcUrl =
|
||||
useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
|
||||
// if we have as method scale, we can return safely the original image, should it be small enough
|
||||
if (getThumbnail &&
|
||||
method == ThumbnailMethod.scale &&
|
||||
thisInfoMap['size'] is int &&
|
||||
thisInfoMap['size'] < minNoThumbSize) {
|
||||
getThumbnail = false;
|
||||
}
|
||||
// now generate the actual URLs
|
||||
if (getThumbnail) {
|
||||
return Uri.parse(thisMxcUrl).getThumbnail(
|
||||
room.client,
|
||||
width: width,
|
||||
height: height,
|
||||
method: method,
|
||||
animated: animated,
|
||||
);
|
||||
} else {
|
||||
return Uri.parse(thisMxcUrl).getDownloadLink(room.client);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns if an attachment is in the local store
|
||||
Future<bool> isAttachmentInLocalStore({bool getThumbnail = false}) async {
|
||||
if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
|
||||
throw ("This event has the type '$type' and so it can't contain an attachment.");
|
||||
}
|
||||
final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
|
||||
if (mxcUrl == null) {
|
||||
throw "This event hasn't any attachment or thumbnail.";
|
||||
}
|
||||
getThumbnail = mxcUrl != attachmentMxcUrl;
|
||||
// Is this file storeable?
|
||||
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
|
||||
final database = room.client.database;
|
||||
if (database == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final storeable = thisInfoMap['size'] is int &&
|
||||
thisInfoMap['size'] <= database.maxFileSize;
|
||||
|
||||
Uint8List? uint8list;
|
||||
if (storeable) {
|
||||
uint8list = await database.getFile(mxcUrl);
|
||||
}
|
||||
return uint8list != null;
|
||||
}
|
||||
|
||||
/// Downloads (and decrypts if necessary) the attachment of this
|
||||
/// event and returns it as a [MatrixFile]. If this event doesn't
|
||||
/// contain an attachment, this throws an error. Set [getThumbnail] to
|
||||
/// true to download the thumbnail instead.
|
||||
Future<MatrixFile> downloadAndDecryptAttachment(
|
||||
{bool getThumbnail = false,
|
||||
Future<Uint8List> Function(Uri)? downloadCallback}) async {
|
||||
if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
|
||||
throw ("This event has the type '$type' and so it can't contain an attachment.");
|
||||
}
|
||||
final database = room.client.database;
|
||||
final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
|
||||
if (mxcUrl == null) {
|
||||
throw "This event hasn't any attachment or thumbnail.";
|
||||
}
|
||||
getThumbnail = mxcUrl != attachmentMxcUrl;
|
||||
final isEncrypted =
|
||||
getThumbnail ? isThumbnailEncrypted : isAttachmentEncrypted;
|
||||
if (isEncrypted && !room.client.encryptionEnabled) {
|
||||
throw ('Encryption is not enabled in your Client.');
|
||||
}
|
||||
|
||||
// Is this file storeable?
|
||||
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
|
||||
var storeable = database != null &&
|
||||
thisInfoMap['size'] is int &&
|
||||
thisInfoMap['size'] <= database.maxFileSize;
|
||||
|
||||
Uint8List? uint8list;
|
||||
if (storeable) {
|
||||
uint8list = await room.client.database?.getFile(mxcUrl);
|
||||
}
|
||||
|
||||
// Download the file
|
||||
if (uint8list == null) {
|
||||
downloadCallback ??= (Uri url) async => (await http.get(url)).bodyBytes;
|
||||
uint8list = await downloadCallback(mxcUrl.getDownloadLink(room.client));
|
||||
storeable = database != null &&
|
||||
storeable &&
|
||||
uint8list.lengthInBytes < database.maxFileSize;
|
||||
if (storeable) {
|
||||
await database.storeFile(
|
||||
mxcUrl, uint8list, DateTime.now().millisecondsSinceEpoch);
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt the file
|
||||
if (isEncrypted) {
|
||||
final fileMap =
|
||||
getThumbnail ? infoMap['thumbnail_file'] : content['file'];
|
||||
if (!fileMap['key']['key_ops'].contains('decrypt')) {
|
||||
throw ("Missing 'decrypt' in 'key_ops'.");
|
||||
}
|
||||
final encryptedFile = EncryptedFile(
|
||||
data: uint8list,
|
||||
iv: fileMap['iv'],
|
||||
k: fileMap['key']['k'],
|
||||
sha256: fileMap['hashes']['sha256'],
|
||||
);
|
||||
uint8list = await room.client.runInBackground<Uint8List?, EncryptedFile>(
|
||||
decryptFile, encryptedFile);
|
||||
if (uint8list == null) {
|
||||
throw ('Unable to decrypt file');
|
||||
}
|
||||
}
|
||||
return MatrixFile(bytes: uint8list, name: body);
|
||||
}
|
||||
|
||||
/// Returns if this is a known event type.
|
||||
bool get isEventTypeKnown =>
|
||||
EventLocalizations.localizationsMap.containsKey(type);
|
||||
|
||||
/// Returns a localized String representation of this event. For a
|
||||
/// room list you may find [withSenderNamePrefix] useful. Set [hideReply] to
|
||||
/// crop all lines starting with '>'. With [plaintextBody] it'll use the
|
||||
/// plaintextBody instead of the normal body.
|
||||
String getLocalizedBody(
|
||||
MatrixLocalizations i18n, {
|
||||
bool withSenderNamePrefix = false,
|
||||
bool hideReply = false,
|
||||
bool hideEdit = false,
|
||||
bool plaintextBody = false,
|
||||
}) {
|
||||
if (redacted) {
|
||||
return i18n.removedBy(redactedBecause?.sender.calcDisplayname() ?? '');
|
||||
}
|
||||
var body = plaintextBody ? this.plaintextBody : this.body;
|
||||
|
||||
// we need to know if the message is an html message to be able to determine
|
||||
// if we need to strip the reply fallback.
|
||||
var htmlMessage = content['format'] != 'org.matrix.custom.html';
|
||||
// If we have an edit, we want to operate on the new content
|
||||
if (hideEdit &&
|
||||
relationshipType == RelationshipTypes.edit &&
|
||||
content.tryGet<Map<String, dynamic>>('m.new_content') != null) {
|
||||
if (plaintextBody &&
|
||||
content['m.new_content']['format'] == 'org.matrix.custom.html') {
|
||||
htmlMessage = true;
|
||||
body = HtmlToText.convert(
|
||||
(content['m.new_content'] as Map<String, dynamic>)
|
||||
.tryGet<String>('formatted_body') ??
|
||||
formattedText);
|
||||
} else {
|
||||
htmlMessage = false;
|
||||
body = (content['m.new_content'] as Map<String, dynamic>)
|
||||
.tryGet<String>('body') ??
|
||||
body;
|
||||
}
|
||||
}
|
||||
// Hide reply fallback
|
||||
// Be sure that the plaintextBody already stripped teh reply fallback,
|
||||
// if the message is formatted
|
||||
if (hideReply && (!plaintextBody || htmlMessage)) {
|
||||
body = body.replaceFirst(
|
||||
RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
|
||||
}
|
||||
final callback = EventLocalizations.localizationsMap[type];
|
||||
var localizedBody = i18n.unknownEvent(type);
|
||||
if (callback != null) {
|
||||
localizedBody = callback(this, i18n, body);
|
||||
}
|
||||
|
||||
// Add the sender name prefix
|
||||
if (withSenderNamePrefix &&
|
||||
type == EventTypes.Message &&
|
||||
textOnlyMessageTypes.contains(messageType)) {
|
||||
final senderNameOrYou = senderId == room.client.userID
|
||||
? i18n.you
|
||||
: (sender.calcDisplayname());
|
||||
localizedBody = '$senderNameOrYou: $localizedBody';
|
||||
}
|
||||
|
||||
return localizedBody;
|
||||
}
|
||||
|
||||
static const Set<String> textOnlyMessageTypes = {
|
||||
MessageTypes.Text,
|
||||
MessageTypes.Notice,
|
||||
MessageTypes.Emote,
|
||||
MessageTypes.None,
|
||||
};
|
||||
|
||||
/// returns if this event matches the passed event or transaction id
|
||||
bool matchesEventOrTransactionId(String? search) {
|
||||
if (search == null) {
|
||||
return false;
|
||||
}
|
||||
if (eventId == search) {
|
||||
return true;
|
||||
}
|
||||
return unsigned?['transaction_id'] == search;
|
||||
}
|
||||
|
||||
/// Get the relationship type of an event. `null` if there is none
|
||||
String? get relationshipType {
|
||||
if (content.tryGet<Map<String, dynamic>>('m.relates_to') == null) {
|
||||
return null;
|
||||
}
|
||||
if (content['m.relates_to'].containsKey('m.in_reply_to')) {
|
||||
return RelationshipTypes.reply;
|
||||
}
|
||||
return content
|
||||
.tryGet<Map<String, dynamic>>('m.relates_to')
|
||||
?.tryGet<String>('rel_type');
|
||||
}
|
||||
|
||||
/// Get the event ID that this relationship will reference. `null` if there is none
|
||||
String? get relationshipEventId {
|
||||
if (!(content['m.relates_to'] is Map)) {
|
||||
return null;
|
||||
}
|
||||
if (content['m.relates_to'].containsKey('event_id')) {
|
||||
return content['m.relates_to']['event_id'];
|
||||
}
|
||||
if (content['m.relates_to']['m.in_reply_to'] is Map &&
|
||||
content['m.relates_to']['m.in_reply_to'].containsKey('event_id')) {
|
||||
return content['m.relates_to']['m.in_reply_to']['event_id'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get whether this event has aggregated events from a certain [type]
|
||||
/// To be able to do that you need to pass a [timeline]
|
||||
bool hasAggregatedEvents(Timeline timeline, String type) =>
|
||||
timeline.aggregatedEvents[eventId]?.containsKey(type) == true;
|
||||
|
||||
/// Get all the aggregated event objects for a given [type]. To be able to do this
|
||||
/// you have to pass a [timeline]
|
||||
Set<Event> aggregatedEvents(Timeline timeline, String type) =>
|
||||
timeline.aggregatedEvents[eventId]?[type] ?? <Event>{};
|
||||
|
||||
/// Fetches the event to be rendered, taking into account all the edits and the like.
|
||||
/// It needs a [timeline] for that.
|
||||
Event getDisplayEvent(Timeline timeline) {
|
||||
if (redacted) {
|
||||
return this;
|
||||
}
|
||||
if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
|
||||
// alright, we have an edit
|
||||
final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit)
|
||||
// we only allow edits made by the original author themself
|
||||
.where((e) => e.senderId == senderId && e.type == EventTypes.Message)
|
||||
.toList();
|
||||
// we need to check again if it isn't empty, as we potentially removed all
|
||||
// aggregated edits
|
||||
if (allEditEvents.isNotEmpty) {
|
||||
allEditEvents.sort((a, b) => a.originServerTs.millisecondsSinceEpoch -
|
||||
b.originServerTs.millisecondsSinceEpoch >
|
||||
0
|
||||
? 1
|
||||
: -1);
|
||||
final rawEvent = allEditEvents.last.toJson();
|
||||
// update the content of the new event to render
|
||||
if (rawEvent['content']['m.new_content'] is Map) {
|
||||
rawEvent['content'] = rawEvent['content']['m.new_content'];
|
||||
}
|
||||
return Event.fromJson(rawEvent, room);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// returns if a message is a rich message
|
||||
bool get isRichMessage =>
|
||||
content['format'] == 'org.matrix.custom.html' &&
|
||||
content['formatted_body'] is String;
|
||||
|
||||
// regexes to fetch the number of emotes, including emoji, and if the message consists of only those
|
||||
// to match an emoji we can use the following regex:
|
||||
// (?:\x{00a9}|\x{00ae}|[\x{2600}-\x{27bf}]|[\x{2b00}-\x{2bff}]|\x{d83c}[\x{d000}-\x{dfff}]|\x{d83d}[\x{d000}-\x{dfff}]|\x{d83e}[\x{d000}-\x{dfff}])[\x{fe00}-\x{fe0f}]?
|
||||
// we need to replace \x{0000} with \u0000, the comment is left in the other format to be able to paste into regex101.com
|
||||
// to see if there is a custom emote, we use the following regex: <img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>
|
||||
// now we combind the two to have four regexes:
|
||||
// 1. are there only emoji, or whitespace
|
||||
// 2. are there only emoji, emotes, or whitespace
|
||||
// 3. count number of emoji
|
||||
// 4- count number of emoji or emotes
|
||||
static final RegExp _onlyEmojiRegex = RegExp(
|
||||
r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$',
|
||||
caseSensitive: false,
|
||||
multiLine: false);
|
||||
static final RegExp _onlyEmojiEmoteRegex = RegExp(
|
||||
r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>|\s)*$',
|
||||
caseSensitive: false,
|
||||
multiLine: false);
|
||||
static final RegExp _countEmojiRegex = RegExp(
|
||||
r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?)',
|
||||
caseSensitive: false,
|
||||
multiLine: false);
|
||||
static final RegExp _countEmojiEmoteRegex = RegExp(
|
||||
r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>)',
|
||||
caseSensitive: false,
|
||||
multiLine: false);
|
||||
|
||||
/// Returns if a given event only has emotes, emojis or whitespace as content.
|
||||
/// If the body contains a reply then it is stripped.
|
||||
/// This is useful to determine if stand-alone emotes should be displayed bigger.
|
||||
bool get onlyEmotes {
|
||||
if (isRichMessage) {
|
||||
final formattedTextStripped = formattedText.replaceAll(
|
||||
RegExp('<mx-reply>.*<\/mx-reply>',
|
||||
caseSensitive: false, multiLine: false, dotAll: true),
|
||||
'');
|
||||
return _onlyEmojiEmoteRegex.hasMatch(formattedTextStripped);
|
||||
} else {
|
||||
return _onlyEmojiRegex.hasMatch(plaintextBody);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the number of emotes in a given message. This is useful to determine
|
||||
/// if the emotes should be displayed bigger.
|
||||
/// If the body contains a reply then it is stripped.
|
||||
/// WARNING: This does **not** test if there are only emotes. Use `event.onlyEmotes` for that!
|
||||
int get numberEmotes {
|
||||
if (isRichMessage) {
|
||||
final formattedTextStripped = formattedText.replaceAll(
|
||||
RegExp('<mx-reply>.*<\/mx-reply>',
|
||||
caseSensitive: false, multiLine: false, dotAll: true),
|
||||
'');
|
||||
return _countEmojiEmoteRegex.allMatches(formattedTextStripped).length;
|
||||
} else {
|
||||
return _countEmojiRegex.allMatches(plaintextBody).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
lib/src/event_status.dart
Normal file
70
lib/src/event_status.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/// Defines event status:
|
||||
/// - removed
|
||||
/// - error: (http request failed)
|
||||
/// - sending: (http request started)
|
||||
/// - sent: (http request successful)
|
||||
/// - synced: (event came from sync loop)
|
||||
/// - roomState
|
||||
enum EventStatus {
|
||||
removed,
|
||||
error,
|
||||
sending,
|
||||
sent,
|
||||
synced,
|
||||
roomState,
|
||||
}
|
||||
|
||||
/// Returns `EventStatusEnum` value from `intValue`.
|
||||
///
|
||||
/// - -2 == removed;
|
||||
/// - -1 == error;
|
||||
/// - 0 == sending;
|
||||
/// - 1 == sent;
|
||||
/// - 2 == synced;
|
||||
/// - 3 == roomState;
|
||||
EventStatus eventStatusFromInt(int intValue) =>
|
||||
EventStatus.values[intValue + 2];
|
||||
|
||||
/// Takes two [EventStatus] values and returns the one with higher
|
||||
/// (better in terms of message sending) status.
|
||||
EventStatus latestEventStatus(EventStatus status1, EventStatus status2) =>
|
||||
status1.intValue > status2.intValue ? status1 : status2;
|
||||
|
||||
extension EventStatusExtension on EventStatus {
|
||||
/// Returns int value of the event status.
|
||||
///
|
||||
/// - -2 == removed;
|
||||
/// - -1 == error;
|
||||
/// - 0 == sending;
|
||||
/// - 1 == sent;
|
||||
/// - 2 == synced;
|
||||
/// - 3 == roomState;
|
||||
int get intValue => (index - 2);
|
||||
|
||||
/// Return `true` if the `EventStatus` equals `removed`.
|
||||
bool get isRemoved => this == EventStatus.removed;
|
||||
|
||||
/// Return `true` if the `EventStatus` equals `error`.
|
||||
bool get isError => this == EventStatus.error;
|
||||
|
||||
/// Return `true` if the `EventStatus` equals `sending`.
|
||||
bool get isSending => this == EventStatus.sending;
|
||||
|
||||
/// Return `true` if the `EventStatus` equals `roomState`.
|
||||
bool get isRoomState => this == EventStatus.roomState;
|
||||
|
||||
/// Returns `true` if the status is sent or later:
|
||||
/// [EventStatus.sent], [EventStatus.synced] or [EventStatus.roomState].
|
||||
bool get isSent => [
|
||||
EventStatus.sent,
|
||||
EventStatus.synced,
|
||||
EventStatus.roomState
|
||||
].contains(this);
|
||||
|
||||
/// Returns `true` if the status is `synced` or `roomState`:
|
||||
/// [EventStatus.synced] or [EventStatus.roomState].
|
||||
bool get isSynced => [
|
||||
EventStatus.synced,
|
||||
EventStatus.roomState,
|
||||
].contains(this);
|
||||
}
|
||||
2100
lib/src/room.dart
Normal file
2100
lib/src/room.dart
Normal file
File diff suppressed because it is too large
Load diff
370
lib/src/timeline.dart
Normal file
370
lib/src/timeline.dart
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
/*
|
||||
* 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:async';
|
||||
|
||||
import 'package:collection/src/iterable_extensions.dart';
|
||||
|
||||
import '../matrix.dart';
|
||||
|
||||
/// Represents the timeline of a room. The callback [onUpdate] will be triggered
|
||||
/// automatically. The initial
|
||||
/// event list will be retreived when created by the `room.getTimeline()` method.
|
||||
class Timeline {
|
||||
final Room room;
|
||||
final List<Event> events;
|
||||
|
||||
/// Map of event ID to map of type to set of aggregated events
|
||||
final Map<String, Map<String, Set<Event>>> aggregatedEvents = {};
|
||||
|
||||
final void Function()? onUpdate;
|
||||
final void Function(int index)? onChange;
|
||||
final void Function(int index)? onInsert;
|
||||
final void Function(int index)? onRemove;
|
||||
|
||||
StreamSubscription<EventUpdate>? sub;
|
||||
StreamSubscription<SyncUpdate>? roomSub;
|
||||
StreamSubscription<String>? sessionIdReceivedSub;
|
||||
bool isRequestingHistory = false;
|
||||
|
||||
final Map<String, Event> _eventCache = {};
|
||||
|
||||
/// Searches for the event in this timeline. If not
|
||||
/// found, requests from the server. Requested events
|
||||
/// are cached.
|
||||
Future<Event?> getEventById(String id) async {
|
||||
for (final event in events) {
|
||||
if (event.eventId == id) return event;
|
||||
}
|
||||
if (_eventCache.containsKey(id)) return _eventCache[id];
|
||||
final requestedEvent = await room.getEventById(id);
|
||||
if (requestedEvent == null) return null;
|
||||
_eventCache[id] = requestedEvent;
|
||||
return _eventCache[id];
|
||||
}
|
||||
|
||||
// When fetching history, we will collect them into the `_historyUpdates` set
|
||||
// first, and then only process all events at once, once we have the full history.
|
||||
// This ensures that the entire history fetching only triggers `onUpdate` only *once*,
|
||||
// even if /sync's complete while history is being proccessed.
|
||||
bool _collectHistoryUpdates = false;
|
||||
|
||||
bool get canRequestHistory {
|
||||
if (events.isEmpty) return true;
|
||||
return events.last.type != EventTypes.RoomCreate;
|
||||
}
|
||||
|
||||
Future<void> requestHistory(
|
||||
{int historyCount = Room.defaultHistoryCount}) async {
|
||||
if (isRequestingHistory) {
|
||||
return;
|
||||
}
|
||||
isRequestingHistory = true;
|
||||
onUpdate?.call();
|
||||
|
||||
try {
|
||||
// Look up for events in hive first
|
||||
final eventsFromStore = await room.client.database?.getEventList(
|
||||
room,
|
||||
start: events.length,
|
||||
limit: Room.defaultHistoryCount,
|
||||
);
|
||||
if (eventsFromStore != null && eventsFromStore.isNotEmpty) {
|
||||
events.addAll(eventsFromStore);
|
||||
final startIndex = events.length - eventsFromStore.length;
|
||||
final endIndex = events.length;
|
||||
for (var i = startIndex; i < endIndex; i++) {
|
||||
onInsert?.call(i);
|
||||
}
|
||||
} else {
|
||||
Logs().v('No more events found in the store. Request from server...');
|
||||
await room.requestHistory(
|
||||
historyCount: historyCount,
|
||||
onHistoryReceived: () {
|
||||
_collectHistoryUpdates = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_collectHistoryUpdates = false;
|
||||
isRequestingHistory = false;
|
||||
onUpdate?.call();
|
||||
}
|
||||
}
|
||||
|
||||
Timeline({
|
||||
required this.room,
|
||||
List<Event>? events,
|
||||
this.onUpdate,
|
||||
this.onChange,
|
||||
this.onInsert,
|
||||
this.onRemove,
|
||||
}) : events = events ?? [] {
|
||||
sub = room.client.onEvent.stream.listen(_handleEventUpdate);
|
||||
|
||||
// If the timeline is limited we want to clear our events cache
|
||||
roomSub = room.client.onSync.stream
|
||||
.where((sync) => sync.rooms?.join?[room.id]?.timeline?.limited == true)
|
||||
.listen(_removeEventsNotInThisSync);
|
||||
|
||||
sessionIdReceivedSub =
|
||||
room.onSessionKeyReceived.stream.listen(_sessionKeyReceived);
|
||||
|
||||
// we want to populate our aggregated events
|
||||
for (final e in this.events) {
|
||||
addAggregatedEvent(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes all entries from [events] which are not in this SyncUpdate.
|
||||
void _removeEventsNotInThisSync(SyncUpdate sync) {
|
||||
final newSyncEvents = sync.rooms?.join?[room.id]?.timeline?.events ?? [];
|
||||
final keepEventIds = newSyncEvents.map((e) => e.eventId);
|
||||
events.removeWhere((e) => !keepEventIds.contains(e.eventId));
|
||||
}
|
||||
|
||||
/// Don't forget to call this before you dismiss this object!
|
||||
void cancelSubscriptions() {
|
||||
sub?.cancel();
|
||||
roomSub?.cancel();
|
||||
sessionIdReceivedSub?.cancel();
|
||||
}
|
||||
|
||||
void _sessionKeyReceived(String sessionId) async {
|
||||
var decryptAtLeastOneEvent = false;
|
||||
final decryptFn = () async {
|
||||
final encryption = room.client.encryption;
|
||||
if (!room.client.encryptionEnabled || encryption == null) {
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
if (events[i].type == EventTypes.Encrypted &&
|
||||
events[i].messageType == MessageTypes.BadEncrypted &&
|
||||
events[i].content['session_id'] == sessionId) {
|
||||
events[i] = await encryption.decryptRoomEvent(room.id, events[i],
|
||||
store: true);
|
||||
onChange?.call(i);
|
||||
if (events[i].type != EventTypes.Encrypted) {
|
||||
decryptAtLeastOneEvent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (room.client.database != null) {
|
||||
await room.client.database?.transaction(decryptFn);
|
||||
} else {
|
||||
await decryptFn();
|
||||
}
|
||||
if (decryptAtLeastOneEvent) onUpdate?.call();
|
||||
}
|
||||
|
||||
/// Request the keys for undecryptable events of this timeline
|
||||
void requestKeys() {
|
||||
for (final event in events) {
|
||||
if (event.type == EventTypes.Encrypted &&
|
||||
event.messageType == MessageTypes.BadEncrypted &&
|
||||
event.content['can_request_session'] == true) {
|
||||
try {
|
||||
room.client.encryption?.keyManager.maybeAutoRequest(room.id,
|
||||
event.content['session_id'], event.content['sender_key']);
|
||||
} catch (_) {
|
||||
// dispose
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the read marker to the last synced event in this timeline.
|
||||
Future<void> setReadMarker([String? eventId]) async {
|
||||
eventId ??=
|
||||
events.firstWhereOrNull((event) => event.status.isSynced)?.eventId;
|
||||
if (eventId == null) return;
|
||||
return room.setReadMarker(eventId, mRead: eventId);
|
||||
}
|
||||
|
||||
int _findEvent({String? event_id, String? unsigned_txid}) {
|
||||
// we want to find any existing event where either the passed event_id or the passed unsigned_txid
|
||||
// matches either the event_id or transaction_id of the existing event.
|
||||
// For that we create two sets, searchNeedle, what we search, and searchHaystack, where we check if there is a match.
|
||||
// Now, after having these two sets, if the intersect between them is non-empty, we know that we have at least one match in one pair,
|
||||
// thus meaning we found our element.
|
||||
final searchNeedle = <String>{};
|
||||
if (event_id != null) {
|
||||
searchNeedle.add(event_id);
|
||||
}
|
||||
if (unsigned_txid != null) {
|
||||
searchNeedle.add(unsigned_txid);
|
||||
}
|
||||
int i;
|
||||
for (i = 0; i < events.length; i++) {
|
||||
final searchHaystack = <String>{events[i].eventId};
|
||||
|
||||
final txnid = events[i].unsigned?['transaction_id'];
|
||||
if (txnid != null) {
|
||||
searchHaystack.add(txnid);
|
||||
}
|
||||
if (searchNeedle.intersection(searchHaystack).isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
void _removeEventFromSet(Set<Event> eventSet, Event event) {
|
||||
eventSet.removeWhere((e) =>
|
||||
e.matchesEventOrTransactionId(event.eventId) ||
|
||||
(event.unsigned != null &&
|
||||
e.matchesEventOrTransactionId(event.unsigned?['transaction_id'])));
|
||||
}
|
||||
|
||||
void addAggregatedEvent(Event event) {
|
||||
// we want to add an event to the aggregation tree
|
||||
final relationshipType = event.relationshipType;
|
||||
final relationshipEventId = event.relationshipEventId;
|
||||
if (relationshipType == null || relationshipEventId == null) {
|
||||
return; // nothing to do
|
||||
}
|
||||
final events = (aggregatedEvents[relationshipEventId] ??=
|
||||
<String, Set<Event>>{})[relationshipType] ??= <Event>{};
|
||||
// remove a potential old event
|
||||
_removeEventFromSet(events, event);
|
||||
// add the new one
|
||||
events.add(event);
|
||||
if (onChange != null) {
|
||||
final index = _findEvent(event_id: relationshipEventId);
|
||||
onChange?.call(index);
|
||||
}
|
||||
}
|
||||
|
||||
void removeAggregatedEvent(Event event) {
|
||||
aggregatedEvents.remove(event.eventId);
|
||||
if (event.unsigned != null) {
|
||||
aggregatedEvents.remove(event.unsigned?['transaction_id']);
|
||||
}
|
||||
for (final types in aggregatedEvents.values) {
|
||||
for (final events in types.values) {
|
||||
_removeEventFromSet(events, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEventUpdate(EventUpdate eventUpdate, {bool update = true}) {
|
||||
try {
|
||||
if (eventUpdate.roomID != room.id) return;
|
||||
|
||||
if (eventUpdate.type != EventUpdateType.timeline &&
|
||||
eventUpdate.type != EventUpdateType.history) {
|
||||
return;
|
||||
}
|
||||
final status = eventStatusFromInt(eventUpdate.content['status'] ??
|
||||
(eventUpdate.content['unsigned'] is Map<String, dynamic>
|
||||
? eventUpdate.content['unsigned'][messageSendingStatusKey]
|
||||
: null) ??
|
||||
EventStatus.synced.intValue);
|
||||
|
||||
if (status.isRemoved) {
|
||||
final i = _findEvent(event_id: eventUpdate.content['event_id']);
|
||||
if (i < events.length) {
|
||||
removeAggregatedEvent(events[i]);
|
||||
events.removeAt(i);
|
||||
onRemove?.call(i);
|
||||
}
|
||||
} else {
|
||||
final i = _findEvent(
|
||||
event_id: eventUpdate.content['event_id'],
|
||||
unsigned_txid: eventUpdate.content['unsigned'] is Map
|
||||
? eventUpdate.content['unsigned']['transaction_id']
|
||||
: null);
|
||||
|
||||
if (i < events.length) {
|
||||
// if the old status is larger than the new one, we also want to preserve the old status
|
||||
final oldStatus = events[i].status;
|
||||
events[i] = Event.fromJson(
|
||||
eventUpdate.content,
|
||||
room,
|
||||
);
|
||||
// do we preserve the status? we should allow 0 -> -1 updates and status increases
|
||||
if ((latestEventStatus(status, oldStatus) == oldStatus) &&
|
||||
!(status.isError && oldStatus.isSending)) {
|
||||
events[i].status = oldStatus;
|
||||
}
|
||||
addAggregatedEvent(events[i]);
|
||||
onChange?.call(i);
|
||||
} else {
|
||||
final newEvent = Event.fromJson(
|
||||
eventUpdate.content,
|
||||
room,
|
||||
);
|
||||
|
||||
if (eventUpdate.type == EventUpdateType.history &&
|
||||
events.indexWhere(
|
||||
(e) => e.eventId == eventUpdate.content['event_id']) !=
|
||||
-1) return;
|
||||
var index = events.length;
|
||||
if (eventUpdate.type == EventUpdateType.history) {
|
||||
events.add(newEvent);
|
||||
} else {
|
||||
index = events.firstIndexWhereNotError;
|
||||
events.insert(index, newEvent);
|
||||
}
|
||||
onInsert?.call(index);
|
||||
|
||||
addAggregatedEvent(newEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle redaction events
|
||||
if (eventUpdate.content['type'] == EventTypes.Redaction) {
|
||||
final index = _findEvent(event_id: eventUpdate.content['redacts']);
|
||||
if (index < events.length) {
|
||||
removeAggregatedEvent(events[index]);
|
||||
|
||||
// Is the redacted event a reaction? Then update the event this
|
||||
// belongs to:
|
||||
if (onChange != null) {
|
||||
final relationshipEventId = events[index].relationshipEventId;
|
||||
if (relationshipEventId != null) {
|
||||
onChange?.call(_findEvent(event_id: relationshipEventId));
|
||||
}
|
||||
}
|
||||
|
||||
events[index].setRedactionEvent(Event.fromJson(
|
||||
eventUpdate.content,
|
||||
room,
|
||||
));
|
||||
onChange?.call(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (update && !_collectHistoryUpdates) {
|
||||
onUpdate?.call();
|
||||
}
|
||||
} catch (e, s) {
|
||||
Logs().w('Handle event update failed', e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on List<Event> {
|
||||
int get firstIndexWhereNotError {
|
||||
if (isEmpty) return 0;
|
||||
final index = indexWhere((event) => !event.status.isError);
|
||||
if (index == -1) return length;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
242
lib/src/user.dart
Normal file
242
lib/src/user.dart
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
* 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 '../matrix.dart';
|
||||
|
||||
/// Represents a Matrix User which may be a participant in a Matrix Room.
|
||||
class User extends Event {
|
||||
factory User(
|
||||
String id, {
|
||||
String? membership,
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
required Room room,
|
||||
}) {
|
||||
return User.fromState(
|
||||
stateKey: id,
|
||||
content: {
|
||||
if (membership != null) 'membership': membership,
|
||||
if (displayName != null) 'displayname': displayName,
|
||||
if (avatarUrl != null) 'avatar_url': avatarUrl,
|
||||
},
|
||||
typeKey: EventTypes.RoomMember,
|
||||
roomId: room.id,
|
||||
room: room,
|
||||
originServerTs: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
User.fromState({
|
||||
dynamic prevContent,
|
||||
required String stateKey,
|
||||
dynamic content,
|
||||
required String typeKey,
|
||||
String eventId = 'fakevent',
|
||||
String? roomId,
|
||||
String senderId = 'fakesender',
|
||||
required DateTime originServerTs,
|
||||
dynamic unsigned,
|
||||
required Room room,
|
||||
}) : super(
|
||||
stateKey: stateKey,
|
||||
prevContent: prevContent,
|
||||
content: content,
|
||||
type: typeKey,
|
||||
eventId: eventId,
|
||||
senderId: senderId,
|
||||
originServerTs: originServerTs,
|
||||
unsigned: unsigned,
|
||||
room: room,
|
||||
);
|
||||
|
||||
/// The full qualified Matrix ID in the format @username:server.abc.
|
||||
String get id => stateKey ?? '@unknown:unknown';
|
||||
|
||||
/// The displayname of the user if the user has set one.
|
||||
String? get displayName =>
|
||||
content.tryGet<String>('displayname') ??
|
||||
prevContent?.tryGet<String>('displayname');
|
||||
|
||||
/// Returns the power level of this user.
|
||||
int get powerLevel => room.getPowerLevelByUserId(id);
|
||||
|
||||
/// The membership status of the user. One of:
|
||||
/// join
|
||||
/// invite
|
||||
/// leave
|
||||
/// ban
|
||||
Membership get membership => Membership.values.firstWhere((e) {
|
||||
if (content['membership'] != null) {
|
||||
return e.toString() == 'Membership.' + content['membership'];
|
||||
}
|
||||
return false;
|
||||
}, orElse: () => Membership.join);
|
||||
|
||||
/// The avatar if the user has one.
|
||||
Uri? get avatarUrl {
|
||||
final prevContent = this.prevContent;
|
||||
return content.containsKey('avatar_url')
|
||||
? (content['avatar_url'] is String
|
||||
? Uri.tryParse(content['avatar_url'])
|
||||
: null)
|
||||
: (prevContent != null && prevContent['avatar_url'] is String
|
||||
? Uri.tryParse(prevContent['avatar_url'])
|
||||
: null);
|
||||
}
|
||||
|
||||
/// Returns the displayname or the local part of the Matrix ID if the user
|
||||
/// has no displayname. If [formatLocalpart] is true, then the localpart will
|
||||
/// be formatted in the way, that all "_" characters are becomming white spaces and
|
||||
/// the first character of each word becomes uppercase.
|
||||
/// If [mxidLocalPartFallback] is true, then the local part of the mxid will be shown
|
||||
/// if there is no other displayname available. If not then this will return "Unknown user".
|
||||
String calcDisplayname({
|
||||
bool? formatLocalpart,
|
||||
bool? mxidLocalPartFallback,
|
||||
}) {
|
||||
formatLocalpart ??= room.client.formatLocalpart;
|
||||
mxidLocalPartFallback ??= room.client.mxidLocalPartFallback;
|
||||
final displayName = this.displayName;
|
||||
if (displayName != null && displayName.isNotEmpty) {
|
||||
return displayName;
|
||||
}
|
||||
final stateKey = this.stateKey;
|
||||
if (stateKey != null && mxidLocalPartFallback) {
|
||||
if (!formatLocalpart) {
|
||||
return stateKey.localpart ?? '';
|
||||
}
|
||||
final words = stateKey.localpart?.replaceAll('_', ' ').split(' ') ?? [];
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
if (words[i].isNotEmpty) {
|
||||
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
|
||||
}
|
||||
}
|
||||
return words.join(' ').trim();
|
||||
}
|
||||
return 'Unknown user';
|
||||
}
|
||||
|
||||
/// Call the Matrix API to kick this user from this room.
|
||||
Future<void> kick() async => await room.kick(id);
|
||||
|
||||
/// Call the Matrix API to ban this user from this room.
|
||||
Future<void> ban() async => await room.ban(id);
|
||||
|
||||
/// Call the Matrix API to unban this banned user from this room.
|
||||
Future<void> unban() async => await room.unban(id);
|
||||
|
||||
/// Call the Matrix API to change the power level of this user.
|
||||
Future<void> setPower(int power) async => await room.setPower(id, power);
|
||||
|
||||
/// Returns an existing direct chat ID with this user or creates a new one.
|
||||
/// Returns null on error.
|
||||
Future<String> startDirectChat({
|
||||
bool? enableEncryption,
|
||||
List<StateEvent>? initialState,
|
||||
bool waitForSync = true,
|
||||
}) async =>
|
||||
room.client.startDirectChat(
|
||||
id,
|
||||
enableEncryption: enableEncryption,
|
||||
initialState: initialState,
|
||||
waitForSync: waitForSync,
|
||||
);
|
||||
|
||||
/// The newest presence of this user if there is any and null if not.
|
||||
Presence? get presence => room.client.presences[id];
|
||||
|
||||
/// Whether the client is able to ban/unban this user.
|
||||
bool get canBan => room.canBan && powerLevel < room.ownPowerLevel;
|
||||
|
||||
/// Whether the client is able to kick this user.
|
||||
bool get canKick =>
|
||||
[Membership.join, Membership.invite].contains(membership) &&
|
||||
room.canKick &&
|
||||
powerLevel < room.ownPowerLevel;
|
||||
|
||||
/// Whether the client is allowed to change the power level of this user.
|
||||
/// Please be aware that you can only set the power level to at least your own!
|
||||
bool get canChangePowerLevel =>
|
||||
room.canChangePowerLevel && powerLevel < room.ownPowerLevel;
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) => (other is User &&
|
||||
other.id == id &&
|
||||
other.room == room &&
|
||||
other.membership == membership);
|
||||
|
||||
/// Get the mention text to use in a plain text body to mention this specific user
|
||||
/// in this specific room
|
||||
String get mention {
|
||||
// if the displayname has [ or ] or : we can't build our more fancy stuff, so fall back to the id
|
||||
// [] is used for the delimitors
|
||||
// If we allowed : we could get collissions with the mxid fallbacks
|
||||
final displayName = this.displayName;
|
||||
if (displayName == null ||
|
||||
displayName.isEmpty ||
|
||||
{'[', ']', ':'}.any(displayName.contains)) {
|
||||
return id;
|
||||
}
|
||||
|
||||
final identifier = '@' +
|
||||
// if we have non-word characters we need to surround with []
|
||||
(RegExp(r'^\w+$').hasMatch(displayName)
|
||||
? displayName
|
||||
: '[$displayName]');
|
||||
|
||||
// get all the users with the same display name
|
||||
final allUsersWithSameDisplayname = room.getParticipants();
|
||||
allUsersWithSameDisplayname.removeWhere((user) =>
|
||||
user.id == id ||
|
||||
(user.displayName?.isEmpty ?? true) ||
|
||||
user.displayName != displayName);
|
||||
if (allUsersWithSameDisplayname.isEmpty) {
|
||||
return identifier;
|
||||
}
|
||||
// ok, we have multiple users with the same display name....time to calculate a hash
|
||||
final hashes = allUsersWithSameDisplayname.map((u) => _hash(u.id));
|
||||
final ourHash = _hash(id);
|
||||
// hash collission...just return our own mxid again
|
||||
if (hashes.contains(ourHash)) {
|
||||
return id;
|
||||
}
|
||||
return '$identifier#$ourHash';
|
||||
}
|
||||
|
||||
/// Get the mention fragments for this user.
|
||||
Set<String> get mentionFragments {
|
||||
final displayName = this.displayName;
|
||||
if (displayName == null ||
|
||||
displayName.isEmpty ||
|
||||
{'[', ']', ':'}.any(displayName.contains)) {
|
||||
return {};
|
||||
}
|
||||
final identifier = '@' +
|
||||
// if we have non-word characters we need to surround with []
|
||||
(RegExp(r'^\w+$').hasMatch(displayName)
|
||||
? displayName
|
||||
: '[$displayName]');
|
||||
|
||||
final hash = _hash(id);
|
||||
return {identifier, '$identifier#$hash'};
|
||||
}
|
||||
}
|
||||
|
||||
const _maximumHashLength = 10000;
|
||||
String _hash(String s) =>
|
||||
(s.codeUnits.fold<int>(0, (a, b) => a + b) % _maximumHashLength).toString();
|
||||
237
lib/src/utils/commands_extension.dart
Normal file
237
lib/src/utils/commands_extension.dart
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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:async';
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
extension CommandsClientExtension on Client {
|
||||
/// Add a command to the command handler. `command` is its name, and `callback` is the
|
||||
/// callback to invoke
|
||||
void addCommand(
|
||||
String command, FutureOr<String?> Function(CommandArgs) callback) {
|
||||
commands[command.toLowerCase()] = callback;
|
||||
}
|
||||
|
||||
/// Parse and execute a string, `msg` is the input. Optionally `inReplyTo` is the event being
|
||||
/// replied to and `editEventId` is the eventId of the event being replied to
|
||||
Future<String?> parseAndRunCommand(Room room, String msg,
|
||||
{Event? inReplyTo, String? editEventId, String? txid}) async {
|
||||
final args = CommandArgs(
|
||||
inReplyTo: inReplyTo,
|
||||
editEventId: editEventId,
|
||||
msg: '',
|
||||
room: room,
|
||||
txid: txid,
|
||||
);
|
||||
if (!msg.startsWith('/')) {
|
||||
final sendCommand = commands['send'];
|
||||
if (sendCommand != null) {
|
||||
args.msg = msg;
|
||||
return await sendCommand(args);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// remove the /
|
||||
msg = msg.substring(1);
|
||||
var command = msg;
|
||||
if (msg.contains(' ')) {
|
||||
final idx = msg.indexOf(' ');
|
||||
command = msg.substring(0, idx).toLowerCase();
|
||||
args.msg = msg.substring(idx + 1);
|
||||
} else {
|
||||
command = msg.toLowerCase();
|
||||
}
|
||||
final commandOp = commands[command];
|
||||
if (commandOp != null) {
|
||||
return await commandOp(args);
|
||||
}
|
||||
if (msg.startsWith('/') && commands.containsKey('send')) {
|
||||
// re-set to include the "command"
|
||||
final sendCommand = commands['send'];
|
||||
if (sendCommand != null) {
|
||||
args.msg = msg;
|
||||
return await sendCommand(args);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Unregister all commands
|
||||
void unregisterAllCommands() {
|
||||
commands.clear();
|
||||
}
|
||||
|
||||
/// Register all default commands
|
||||
void registerDefaultCommands() {
|
||||
addCommand('send', (CommandArgs args) async {
|
||||
return await args.room.sendTextEvent(
|
||||
args.msg,
|
||||
inReplyTo: args.inReplyTo,
|
||||
editEventId: args.editEventId,
|
||||
parseCommands: false,
|
||||
txid: args.txid,
|
||||
);
|
||||
});
|
||||
addCommand('me', (CommandArgs args) async {
|
||||
return await args.room.sendTextEvent(
|
||||
args.msg,
|
||||
inReplyTo: args.inReplyTo,
|
||||
editEventId: args.editEventId,
|
||||
msgtype: MessageTypes.Emote,
|
||||
parseCommands: false,
|
||||
txid: args.txid,
|
||||
);
|
||||
});
|
||||
addCommand('dm', (CommandArgs args) async {
|
||||
final parts = args.msg.split(' ');
|
||||
return await args.room.client.startDirectChat(
|
||||
parts.first,
|
||||
enableEncryption: !parts.any((part) => part == '--no-encryption'),
|
||||
);
|
||||
});
|
||||
addCommand('create', (CommandArgs args) async {
|
||||
final parts = args.msg.split(' ');
|
||||
return await args.room.client.createGroupChat(
|
||||
enableEncryption: !parts.any((part) => part == '--no-encryption'),
|
||||
);
|
||||
});
|
||||
addCommand('plain', (CommandArgs args) async {
|
||||
return await args.room.sendTextEvent(
|
||||
args.msg,
|
||||
inReplyTo: args.inReplyTo,
|
||||
editEventId: args.editEventId,
|
||||
parseMarkdown: false,
|
||||
parseCommands: false,
|
||||
txid: args.txid,
|
||||
);
|
||||
});
|
||||
addCommand('html', (CommandArgs args) async {
|
||||
final event = <String, dynamic>{
|
||||
'msgtype': 'm.text',
|
||||
'body': args.msg,
|
||||
'format': 'org.matrix.custom.html',
|
||||
'formatted_body': args.msg,
|
||||
};
|
||||
return await args.room.sendEvent(
|
||||
event,
|
||||
inReplyTo: args.inReplyTo,
|
||||
editEventId: args.editEventId,
|
||||
txid: args.txid,
|
||||
);
|
||||
});
|
||||
addCommand('react', (CommandArgs args) async {
|
||||
final inReplyTo = args.inReplyTo;
|
||||
if (inReplyTo == null) {
|
||||
return null;
|
||||
}
|
||||
return await args.room.sendReaction(inReplyTo.eventId, args.msg);
|
||||
});
|
||||
addCommand('join', (CommandArgs args) async {
|
||||
await args.room.client.joinRoom(args.msg);
|
||||
return null;
|
||||
});
|
||||
addCommand('leave', (CommandArgs args) async {
|
||||
await args.room.leave();
|
||||
return '';
|
||||
});
|
||||
addCommand('op', (CommandArgs args) async {
|
||||
final parts = args.msg.split(' ');
|
||||
if (parts.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
int? pl;
|
||||
if (parts.length >= 2) {
|
||||
pl = int.tryParse(parts[1]);
|
||||
}
|
||||
final mxid = parts.first;
|
||||
return await args.room.setPower(mxid, pl ?? 50);
|
||||
});
|
||||
addCommand('kick', (CommandArgs args) async {
|
||||
final parts = args.msg.split(' ');
|
||||
await args.room.kick(parts.first);
|
||||
return '';
|
||||
});
|
||||
addCommand('ban', (CommandArgs args) async {
|
||||
final parts = args.msg.split(' ');
|
||||
await args.room.ban(parts.first);
|
||||
return '';
|
||||
});
|
||||
addCommand('unban', (CommandArgs args) async {
|
||||
final parts = args.msg.split(' ');
|
||||
await args.room.unban(parts.first);
|
||||
return '';
|
||||
});
|
||||
addCommand('invite', (CommandArgs args) async {
|
||||
final parts = args.msg.split(' ');
|
||||
await args.room.invite(parts.first);
|
||||
return '';
|
||||
});
|
||||
addCommand('myroomnick', (CommandArgs args) async {
|
||||
final currentEventJson = args.room
|
||||
.getState(EventTypes.RoomMember, args.room.client.userID!)
|
||||
?.content
|
||||
.copy() ??
|
||||
{};
|
||||
currentEventJson['displayname'] = args.msg;
|
||||
return await args.room.client.setRoomStateWithKey(
|
||||
args.room.id,
|
||||
EventTypes.RoomMember,
|
||||
args.room.client.userID!,
|
||||
currentEventJson,
|
||||
);
|
||||
});
|
||||
addCommand('myroomavatar', (CommandArgs args) async {
|
||||
final currentEventJson = args.room
|
||||
.getState(EventTypes.RoomMember, args.room.client.userID!)
|
||||
?.content
|
||||
.copy() ??
|
||||
{};
|
||||
currentEventJson['avatar_url'] = args.msg;
|
||||
return await args.room.client.setRoomStateWithKey(
|
||||
args.room.id,
|
||||
EventTypes.RoomMember,
|
||||
args.room.client.userID!,
|
||||
currentEventJson,
|
||||
);
|
||||
});
|
||||
addCommand('discardsession', (CommandArgs args) async {
|
||||
await encryption?.keyManager
|
||||
.clearOrUseOutboundGroupSession(args.room.id, wipe: true);
|
||||
return '';
|
||||
});
|
||||
addCommand('clearcache', (CommandArgs args) async {
|
||||
await clearCache();
|
||||
return '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class CommandArgs {
|
||||
String msg;
|
||||
String? editEventId;
|
||||
Event? inReplyTo;
|
||||
Room room;
|
||||
String? txid;
|
||||
CommandArgs(
|
||||
{required this.msg,
|
||||
this.editEventId,
|
||||
this.inReplyTo,
|
||||
required this.room,
|
||||
this.txid});
|
||||
}
|
||||
29
lib/src/utils/crypto/crypto.dart
Normal file
29
lib/src/utils/crypto/crypto.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export 'native.dart' if (dart.library.js) 'js.dart';
|
||||
|
||||
import 'dart:typed_data';
|
||||
import 'dart:math';
|
||||
|
||||
Uint8List secureRandomBytes(int len) {
|
||||
final rng = Random.secure();
|
||||
final list = Uint8List(len);
|
||||
list.setAll(0, Iterable.generate(list.length, (i) => rng.nextInt(256)));
|
||||
return list;
|
||||
}
|
||||
60
lib/src/utils/crypto/encrypted_file.dart
Normal file
60
lib/src/utils/crypto/encrypted_file.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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:typed_data';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:matrix/encryption/utils/base64_unpadded.dart';
|
||||
|
||||
import 'crypto.dart';
|
||||
|
||||
class EncryptedFile {
|
||||
EncryptedFile({
|
||||
required this.data,
|
||||
required this.k,
|
||||
required this.iv,
|
||||
required this.sha256,
|
||||
});
|
||||
Uint8List data;
|
||||
String k;
|
||||
String iv;
|
||||
String sha256;
|
||||
}
|
||||
|
||||
Future<EncryptedFile> encryptFile(Uint8List input) async {
|
||||
final key = secureRandomBytes(32);
|
||||
final iv = secureRandomBytes(16);
|
||||
final data = await aesCtr.encrypt(input, key, iv);
|
||||
final hash = await sha256(data);
|
||||
return EncryptedFile(
|
||||
data: data,
|
||||
k: base64Url.encode(key).replaceAll('=', ''),
|
||||
iv: base64.encode(iv).replaceAll('=', ''),
|
||||
sha256: base64.encode(hash).replaceAll('=', ''),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> decryptFile(EncryptedFile input) async {
|
||||
if (base64.encode(await sha256(input.data)) !=
|
||||
base64.normalize(input.sha256)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final key = base64decodeUnpadded(base64.normalize(input.k));
|
||||
final iv = base64decodeUnpadded(base64.normalize(input.iv));
|
||||
return await aesCtr.encrypt(input.data, key, iv);
|
||||
}
|
||||
123
lib/src/utils/crypto/ffi.dart
Normal file
123
lib/src/utils/crypto/ffi.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* 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:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
final libcrypto = Platform.isIOS
|
||||
? DynamicLibrary.process()
|
||||
: DynamicLibrary.open(Platform.isAndroid
|
||||
? 'libcrypto.so'
|
||||
: Platform.isWindows
|
||||
? 'libcrypto.dll'
|
||||
: Platform.isMacOS
|
||||
? 'libcrypto.1.1.dylib'
|
||||
: 'libcrypto.so.1.1');
|
||||
|
||||
final PKCS5_PBKDF2_HMAC = libcrypto.lookupFunction<
|
||||
IntPtr Function(
|
||||
Pointer<Uint8> pass,
|
||||
IntPtr passlen,
|
||||
Pointer<Uint8> salt,
|
||||
IntPtr saltlen,
|
||||
IntPtr iter,
|
||||
Pointer<NativeType> digest,
|
||||
IntPtr keylen,
|
||||
Pointer<Uint8> out),
|
||||
int Function(
|
||||
Pointer<Uint8> pass,
|
||||
int passlen,
|
||||
Pointer<Uint8> salt,
|
||||
int saltlen,
|
||||
int iter,
|
||||
Pointer<NativeType> digest,
|
||||
int keylen,
|
||||
Pointer<Uint8> out)>('PKCS5_PBKDF2_HMAC');
|
||||
|
||||
final EVP_sha1 = libcrypto.lookupFunction<Pointer<NativeType> Function(),
|
||||
Pointer<NativeType> Function()>('EVP_sha1');
|
||||
|
||||
final EVP_sha256 = libcrypto.lookupFunction<Pointer<NativeType> Function(),
|
||||
Pointer<NativeType> Function()>('EVP_sha256');
|
||||
|
||||
final EVP_sha512 = libcrypto.lookupFunction<Pointer<NativeType> Function(),
|
||||
Pointer<NativeType> Function()>('EVP_sha512');
|
||||
|
||||
final EVP_aes_128_ctr = libcrypto.lookupFunction<Pointer<NativeType> Function(),
|
||||
Pointer<NativeType> Function()>('EVP_aes_128_ctr');
|
||||
|
||||
final EVP_aes_256_ctr = libcrypto.lookupFunction<Pointer<NativeType> Function(),
|
||||
Pointer<NativeType> Function()>('EVP_aes_256_ctr');
|
||||
|
||||
final EVP_CIPHER_CTX_new = libcrypto.lookupFunction<
|
||||
Pointer<NativeType> Function(),
|
||||
Pointer<NativeType> Function()>('EVP_CIPHER_CTX_new');
|
||||
|
||||
final EVP_EncryptInit_ex = libcrypto.lookupFunction<
|
||||
Pointer<NativeType> Function(
|
||||
Pointer<NativeType> ctx,
|
||||
Pointer<NativeType> alg,
|
||||
Pointer<NativeType> some,
|
||||
Pointer<Uint8> key,
|
||||
Pointer<Uint8> iv),
|
||||
Pointer<NativeType> Function(
|
||||
Pointer<NativeType> ctx,
|
||||
Pointer<NativeType> alg,
|
||||
Pointer<NativeType> some,
|
||||
Pointer<Uint8> key,
|
||||
Pointer<Uint8> iv)>('EVP_EncryptInit_ex');
|
||||
|
||||
final EVP_EncryptUpdate = libcrypto.lookupFunction<
|
||||
Pointer<NativeType> Function(Pointer<NativeType> ctx, Pointer<Uint8> output,
|
||||
Pointer<IntPtr> outputLen, Pointer<Uint8> input, IntPtr inputLen),
|
||||
Pointer<NativeType> Function(
|
||||
Pointer<NativeType> ctx,
|
||||
Pointer<Uint8> output,
|
||||
Pointer<IntPtr> outputLen,
|
||||
Pointer<Uint8> input,
|
||||
int inputLen)>('EVP_EncryptUpdate');
|
||||
|
||||
final EVP_EncryptFinal_ex = libcrypto.lookupFunction<
|
||||
Pointer<NativeType> Function(
|
||||
Pointer<NativeType> ctx, Pointer<Uint8> data, Pointer<IntPtr> len),
|
||||
Pointer<NativeType> Function(Pointer<NativeType> ctx, Pointer<Uint8> data,
|
||||
Pointer<IntPtr> len)>('EVP_EncryptFinal_ex');
|
||||
|
||||
final EVP_CIPHER_CTX_free = libcrypto.lookupFunction<
|
||||
Pointer<NativeType> Function(Pointer<NativeType> ctx),
|
||||
Pointer<NativeType> Function(
|
||||
Pointer<NativeType> ctx)>('EVP_CIPHER_CTX_free');
|
||||
|
||||
final EVP_Digest = libcrypto.lookupFunction<
|
||||
IntPtr Function(
|
||||
Pointer<Uint8> data,
|
||||
IntPtr len,
|
||||
Pointer<Uint8> hash,
|
||||
Pointer<IntPtr> hsize,
|
||||
Pointer<NativeType> alg,
|
||||
Pointer<NativeType> engine),
|
||||
int Function(
|
||||
Pointer<Uint8> data,
|
||||
int len,
|
||||
Pointer<Uint8> hash,
|
||||
Pointer<IntPtr> hsize,
|
||||
Pointer<NativeType> alg,
|
||||
Pointer<NativeType> engine)>('EVP_Digest');
|
||||
|
||||
final EVP_MD_size = libcrypto.lookupFunction<
|
||||
IntPtr Function(Pointer<NativeType> ctx),
|
||||
int Function(Pointer<NativeType> ctx)>('EVP_MD_size');
|
||||
64
lib/src/utils/crypto/js.dart
Normal file
64
lib/src/utils/crypto/js.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Copyright (c) 2020 Famedly GmbH
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'subtle.dart';
|
||||
import 'subtle.dart' as subtle;
|
||||
|
||||
abstract class Hash {
|
||||
Hash._(this.name);
|
||||
String name;
|
||||
|
||||
Future<Uint8List> call(Uint8List input) async =>
|
||||
Uint8List.view(await digest(name, input));
|
||||
}
|
||||
|
||||
final Hash sha1 = _Sha1();
|
||||
final Hash sha256 = _Sha256();
|
||||
final Hash sha512 = _Sha512();
|
||||
|
||||
class _Sha1 extends Hash {
|
||||
_Sha1() : super._('SHA-1');
|
||||
}
|
||||
|
||||
class _Sha256 extends Hash {
|
||||
_Sha256() : super._('SHA-256');
|
||||
}
|
||||
|
||||
class _Sha512 extends Hash {
|
||||
_Sha512() : super._('SHA-512');
|
||||
}
|
||||
|
||||
abstract class Cipher {
|
||||
Cipher._(this.name);
|
||||
String name;
|
||||
Object params(Uint8List iv);
|
||||
Future<Uint8List> encrypt(
|
||||
Uint8List input, Uint8List key, Uint8List iv) async {
|
||||
final subtleKey = await importKey('raw', key, name, false, ['encrypt']);
|
||||
return (await subtle.encrypt(params(iv), subtleKey, input)).asUint8List();
|
||||
}
|
||||
}
|
||||
|
||||
final Cipher aesCtr = _AesCtr();
|
||||
|
||||
class _AesCtr extends Cipher {
|
||||
_AesCtr() : super._('AES-CTR');
|
||||
|
||||
@override
|
||||
Object params(Uint8List iv) =>
|
||||
AesCtrParams(name: name, counter: iv, length: 64);
|
||||
}
|
||||
|
||||
Future<Uint8List> pbkdf2(Uint8List passphrase, Uint8List salt, Hash hash,
|
||||
int iterations, int bits) async {
|
||||
final raw =
|
||||
await importKey('raw', passphrase, 'PBKDF2', false, ['deriveBits']);
|
||||
final res = await deriveBits(
|
||||
Pbkdf2Params(
|
||||
name: 'PBKDF2', hash: hash.name, salt: salt, iterations: iterations),
|
||||
raw,
|
||||
bits);
|
||||
return Uint8List.view(res);
|
||||
}
|
||||
102
lib/src/utils/crypto/native.dart
Normal file
102
lib/src/utils/crypto/native.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ffi';
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
import 'ffi.dart';
|
||||
|
||||
abstract class Hash {
|
||||
Hash._(this.ptr);
|
||||
Pointer<NativeType> ptr;
|
||||
|
||||
FutureOr<Uint8List> call(Uint8List data) {
|
||||
final outSize = EVP_MD_size(ptr);
|
||||
final mem = malloc.call<Uint8>(outSize + data.length);
|
||||
final dataMem = mem.elementAt(outSize);
|
||||
try {
|
||||
dataMem.asTypedList(data.length).setAll(0, data);
|
||||
EVP_Digest(dataMem, data.length, mem, nullptr, ptr, nullptr);
|
||||
return Uint8List.fromList(mem.asTypedList(outSize));
|
||||
} finally {
|
||||
malloc.free(mem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final Hash sha1 = _Sha1();
|
||||
final Hash sha256 = _Sha256();
|
||||
final Hash sha512 = _Sha512();
|
||||
|
||||
class _Sha1 extends Hash {
|
||||
_Sha1() : super._(EVP_sha1());
|
||||
}
|
||||
|
||||
class _Sha256 extends Hash {
|
||||
_Sha256() : super._(EVP_sha256());
|
||||
}
|
||||
|
||||
class _Sha512 extends Hash {
|
||||
_Sha512() : super._(EVP_sha512());
|
||||
}
|
||||
|
||||
abstract class Cipher {
|
||||
Cipher._();
|
||||
Pointer<NativeType> getAlg(int keysize);
|
||||
FutureOr<Uint8List> encrypt(Uint8List input, Uint8List key, Uint8List iv) {
|
||||
final alg = getAlg(key.length * 8);
|
||||
final mem = malloc
|
||||
.call<Uint8>(sizeOf<IntPtr>() + key.length + iv.length + input.length);
|
||||
final lenMem = mem.cast<IntPtr>();
|
||||
final keyMem = mem.elementAt(sizeOf<IntPtr>());
|
||||
final ivMem = keyMem.elementAt(key.length);
|
||||
final dataMem = ivMem.elementAt(iv.length);
|
||||
try {
|
||||
keyMem.asTypedList(key.length).setAll(0, key);
|
||||
ivMem.asTypedList(iv.length).setAll(0, iv);
|
||||
dataMem.asTypedList(input.length).setAll(0, input);
|
||||
final ctx = EVP_CIPHER_CTX_new();
|
||||
EVP_EncryptInit_ex(ctx, alg, nullptr, keyMem, ivMem);
|
||||
EVP_EncryptUpdate(ctx, dataMem, lenMem, dataMem, input.length);
|
||||
EVP_EncryptFinal_ex(ctx, dataMem.elementAt(lenMem.value), lenMem);
|
||||
EVP_CIPHER_CTX_free(ctx);
|
||||
return Uint8List.fromList(dataMem.asTypedList(input.length));
|
||||
} finally {
|
||||
malloc.free(mem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final Cipher aesCtr = _AesCtr();
|
||||
|
||||
class _AesCtr extends Cipher {
|
||||
_AesCtr() : super._();
|
||||
|
||||
@override
|
||||
Pointer<NativeType> getAlg(int keysize) {
|
||||
switch (keysize) {
|
||||
case 128:
|
||||
return EVP_aes_128_ctr();
|
||||
case 256:
|
||||
return EVP_aes_256_ctr();
|
||||
default:
|
||||
throw ArgumentError('invalid key size');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FutureOr<Uint8List> pbkdf2(
|
||||
Uint8List passphrase, Uint8List salt, Hash hash, int iterations, int bits) {
|
||||
final outLen = bits ~/ 8;
|
||||
final mem = malloc.call<Uint8>(passphrase.length + salt.length + outLen);
|
||||
final saltMem = mem.elementAt(passphrase.length);
|
||||
final outMem = saltMem.elementAt(salt.length);
|
||||
try {
|
||||
mem.asTypedList(passphrase.length).setAll(0, passphrase);
|
||||
saltMem.asTypedList(salt.length).setAll(0, salt);
|
||||
PKCS5_PBKDF2_HMAC(mem, passphrase.length, saltMem, salt.length, iterations,
|
||||
hash.ptr, outLen, outMem);
|
||||
return Uint8List.fromList(outMem.asTypedList(outLen));
|
||||
} finally {
|
||||
malloc.free(mem);
|
||||
}
|
||||
}
|
||||
93
lib/src/utils/crypto/subtle.dart
Normal file
93
lib/src/utils/crypto/subtle.dart
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Copyright (c) 2020 Famedly GmbH
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
@JS()
|
||||
library subtle;
|
||||
|
||||
import 'package:js/js.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:js_util';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@JS()
|
||||
@anonymous
|
||||
class Pbkdf2Params {
|
||||
external factory Pbkdf2Params({
|
||||
String name,
|
||||
String hash,
|
||||
Uint8List salt,
|
||||
int iterations,
|
||||
});
|
||||
String? name;
|
||||
String? hash;
|
||||
Uint8List? salt;
|
||||
int? iterations;
|
||||
}
|
||||
|
||||
@JS()
|
||||
@anonymous
|
||||
class AesCtrParams {
|
||||
external factory AesCtrParams({
|
||||
String name,
|
||||
Uint8List counter,
|
||||
int length,
|
||||
});
|
||||
String? name;
|
||||
Uint8List? counter;
|
||||
int? length;
|
||||
}
|
||||
|
||||
@JS('crypto.subtle.encrypt')
|
||||
external dynamic _encrypt(dynamic algorithm, dynamic key, Uint8List data);
|
||||
|
||||
Future<ByteBuffer> encrypt(dynamic algorithm, dynamic key, Uint8List data) {
|
||||
return promiseToFuture(_encrypt(algorithm, key, data));
|
||||
}
|
||||
|
||||
@JS('crypto.subtle.decrypt')
|
||||
external dynamic _decrypt(dynamic algorithm, dynamic key, Uint8List data);
|
||||
|
||||
Future<ByteBuffer> decrypt(dynamic algorithm, dynamic key, Uint8List data) {
|
||||
return promiseToFuture(_decrypt(algorithm, key, data));
|
||||
}
|
||||
|
||||
@JS('crypto.subtle.importKey')
|
||||
external dynamic _importKey(String format, dynamic keyData, dynamic algorithm,
|
||||
bool extractable, List<String> keyUsages);
|
||||
|
||||
Future<dynamic> importKey(String format, dynamic keyData, dynamic algorithm,
|
||||
bool extractable, List<String> keyUsages) {
|
||||
return promiseToFuture(
|
||||
_importKey(format, keyData, algorithm, extractable, keyUsages));
|
||||
}
|
||||
|
||||
@JS('crypto.subtle.exportKey')
|
||||
external dynamic _exportKey(String algorithm, dynamic key);
|
||||
|
||||
Future<dynamic> exportKey(String algorithm, dynamic key) {
|
||||
return promiseToFuture(_exportKey(algorithm, key));
|
||||
}
|
||||
|
||||
@JS('crypto.subtle.deriveKey')
|
||||
external dynamic _deriveKey(dynamic algorithm, dynamic baseKey,
|
||||
dynamic derivedKeyAlgorithm, bool extractable, List<String> keyUsages);
|
||||
|
||||
Future<ByteBuffer> deriveKey(dynamic algorithm, dynamic baseKey,
|
||||
dynamic derivedKeyAlgorithm, bool extractable, List<String> keyUsages) {
|
||||
return promiseToFuture(_deriveKey(
|
||||
algorithm, baseKey, derivedKeyAlgorithm, extractable, keyUsages));
|
||||
}
|
||||
|
||||
@JS('crypto.subtle.deriveBits')
|
||||
external dynamic _deriveBits(dynamic algorithm, dynamic baseKey, int length);
|
||||
|
||||
Future<ByteBuffer> deriveBits(dynamic algorithm, dynamic baseKey, int length) {
|
||||
return promiseToFuture(_deriveBits(algorithm, baseKey, length));
|
||||
}
|
||||
|
||||
@JS('crypto.subtle.digest')
|
||||
external dynamic _digest(String algorithm, Uint8List data);
|
||||
|
||||
Future<ByteBuffer> digest(String algorithm, Uint8List data) {
|
||||
return promiseToFuture(_digest(algorithm, data));
|
||||
}
|
||||
515
lib/src/utils/device_keys_list.dart
Normal file
515
lib/src/utils/device_keys_list.dart
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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:canonical_json/canonical_json.dart';
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import '../../encryption.dart';
|
||||
|
||||
enum UserVerifiedStatus { verified, unknown, unknownDevice }
|
||||
|
||||
class DeviceKeysList {
|
||||
Client client;
|
||||
String userId;
|
||||
bool outdated = true;
|
||||
Map<String, DeviceKeys> deviceKeys = {};
|
||||
Map<String, CrossSigningKey> crossSigningKeys = {};
|
||||
|
||||
SignableKey? getKey(String id) => deviceKeys[id] ?? crossSigningKeys[id];
|
||||
|
||||
CrossSigningKey? getCrossSigningKey(String type) =>
|
||||
crossSigningKeys.values.firstWhereOrNull((k) => k.usage.contains(type));
|
||||
|
||||
CrossSigningKey? get masterKey => getCrossSigningKey('master');
|
||||
CrossSigningKey? get selfSigningKey => getCrossSigningKey('self_signing');
|
||||
CrossSigningKey? get userSigningKey => getCrossSigningKey('user_signing');
|
||||
|
||||
UserVerifiedStatus get verified {
|
||||
if (masterKey == null) {
|
||||
return UserVerifiedStatus.unknown;
|
||||
}
|
||||
if (masterKey!.verified) {
|
||||
for (final key in deviceKeys.values) {
|
||||
if (!key.verified) {
|
||||
return UserVerifiedStatus.unknownDevice;
|
||||
}
|
||||
}
|
||||
return UserVerifiedStatus.verified;
|
||||
} else {
|
||||
for (final key in deviceKeys.values) {
|
||||
if (!key.verified) {
|
||||
return UserVerifiedStatus.unknown;
|
||||
}
|
||||
}
|
||||
return UserVerifiedStatus.verified;
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a verification with this device. This might need to create a new
|
||||
/// direct chat to send the verification request over this room. For this you
|
||||
/// can set parameters here.
|
||||
Future<KeyVerification> startVerification({
|
||||
bool? newDirectChatEnableEncryption,
|
||||
List<StateEvent>? newDirectChatInitialState,
|
||||
}) async {
|
||||
final encryption = client.encryption;
|
||||
if (encryption == null) {
|
||||
throw Exception('Encryption not enabled');
|
||||
}
|
||||
if (userId != client.userID) {
|
||||
// in-room verification with someone else
|
||||
final roomId = await client.startDirectChat(
|
||||
userId,
|
||||
enableEncryption: newDirectChatEnableEncryption,
|
||||
initialState: newDirectChatInitialState,
|
||||
waitForSync: false,
|
||||
);
|
||||
|
||||
final room =
|
||||
client.getRoomById(roomId) ?? Room(id: roomId, client: client);
|
||||
final request =
|
||||
KeyVerification(encryption: encryption, room: room, userId: userId);
|
||||
await request.start();
|
||||
// no need to add to the request client object. As we are doing a room
|
||||
// verification request that'll happen automatically once we know the transaction id
|
||||
return request;
|
||||
} else {
|
||||
// broadcast self-verification
|
||||
final request = KeyVerification(
|
||||
encryption: encryption, userId: userId, deviceId: '*');
|
||||
await request.start();
|
||||
encryption.keyVerificationManager.addRequest(request);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
DeviceKeysList.fromDbJson(
|
||||
Map<String, dynamic> dbEntry,
|
||||
List<Map<String, dynamic>> childEntries,
|
||||
List<Map<String, dynamic>> crossSigningEntries,
|
||||
Client cl)
|
||||
: client = cl,
|
||||
userId = dbEntry['user_id'] ?? '' {
|
||||
outdated = dbEntry['outdated'];
|
||||
deviceKeys = {};
|
||||
for (final childEntry in childEntries) {
|
||||
final entry = DeviceKeys.fromDb(childEntry, client);
|
||||
if (entry.isValid) {
|
||||
deviceKeys[childEntry['device_id']] = entry;
|
||||
} else {
|
||||
outdated = true;
|
||||
}
|
||||
}
|
||||
for (final crossSigningEntry in crossSigningEntries) {
|
||||
final entry = CrossSigningKey.fromDbJson(crossSigningEntry, client);
|
||||
if (entry.isValid) {
|
||||
crossSigningKeys[crossSigningEntry['public_key']] = entry;
|
||||
} else {
|
||||
outdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DeviceKeysList(this.userId, this.client);
|
||||
}
|
||||
|
||||
class SimpleSignableKey extends MatrixSignableKey {
|
||||
@override
|
||||
String? identifier;
|
||||
|
||||
SimpleSignableKey.fromJson(Map<String, dynamic> json) : super.fromJson(json);
|
||||
}
|
||||
|
||||
abstract class SignableKey extends MatrixSignableKey {
|
||||
Client client;
|
||||
Map<String, dynamic>? validSignatures;
|
||||
bool? _verified;
|
||||
bool? _blocked;
|
||||
|
||||
String? get ed25519Key => keys['ed25519:$identifier'];
|
||||
bool get verified =>
|
||||
identifier != null && (directVerified || crossVerified) && !(blocked);
|
||||
bool get blocked => _blocked ?? false;
|
||||
set blocked(bool b) => _blocked = b;
|
||||
|
||||
bool get encryptToDevice =>
|
||||
!(blocked) &&
|
||||
identifier != null &&
|
||||
ed25519Key != null &&
|
||||
(client.userDeviceKeys[userId]?.masterKey?.verified ?? false
|
||||
? verified
|
||||
: true);
|
||||
|
||||
void setDirectVerified(bool v) {
|
||||
_verified = v;
|
||||
}
|
||||
|
||||
bool get directVerified => _verified ?? false;
|
||||
bool get crossVerified => hasValidSignatureChain();
|
||||
bool get signed => hasValidSignatureChain(verifiedOnly: false);
|
||||
|
||||
SignableKey.fromJson(Map<String, dynamic> json, Client cl)
|
||||
: client = cl,
|
||||
super.fromJson(json) {
|
||||
_verified = false;
|
||||
_blocked = false;
|
||||
}
|
||||
|
||||
SimpleSignableKey cloneForSigning() {
|
||||
final newKey = SimpleSignableKey.fromJson(toJson().copy());
|
||||
newKey.identifier = identifier;
|
||||
(newKey.signatures ??= {}).clear();
|
||||
return newKey;
|
||||
}
|
||||
|
||||
String get signingContent {
|
||||
final data = super.toJson().copy();
|
||||
// some old data might have the custom verified and blocked keys
|
||||
data.remove('verified');
|
||||
data.remove('blocked');
|
||||
// remove the keys not needed for signing
|
||||
data.remove('unsigned');
|
||||
data.remove('signatures');
|
||||
return String.fromCharCodes(canonicalJson.encode(data));
|
||||
}
|
||||
|
||||
bool _verifySignature(String pubKey, String signature,
|
||||
{bool isSignatureWithoutLibolmValid = false}) {
|
||||
olm.Utility olmutil;
|
||||
try {
|
||||
olmutil = olm.Utility();
|
||||
} catch (e) {
|
||||
// if no libolm is present we land in this catch block, and return the default
|
||||
// set if no libolm is there. Some signatures should be assumed-valid while others
|
||||
// should be assumed-invalid
|
||||
return isSignatureWithoutLibolmValid;
|
||||
}
|
||||
var valid = false;
|
||||
try {
|
||||
olmutil.ed25519_verify(pubKey, signingContent, signature);
|
||||
valid = true;
|
||||
} catch (_) {
|
||||
// bad signature
|
||||
valid = false;
|
||||
} finally {
|
||||
olmutil.free();
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
bool hasValidSignatureChain(
|
||||
{bool verifiedOnly = true,
|
||||
Set<String>? visited,
|
||||
Set<String>? onlyValidateUserIds}) {
|
||||
if (!client.encryptionEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final visited_ = visited ?? <String>{};
|
||||
final onlyValidateUserIds_ = onlyValidateUserIds ?? <String>{};
|
||||
|
||||
final setKey = '$userId;$identifier';
|
||||
if (visited_.contains(setKey) ||
|
||||
(onlyValidateUserIds_.isNotEmpty &&
|
||||
!onlyValidateUserIds_.contains(userId))) {
|
||||
return false; // prevent recursion & validate hasValidSignatureChain
|
||||
}
|
||||
visited_.add(setKey);
|
||||
|
||||
if (signatures == null) return false;
|
||||
|
||||
for (final signatureEntries in signatures!.entries) {
|
||||
final otherUserId = signatureEntries.key;
|
||||
if (!client.userDeviceKeys.containsKey(otherUserId)) {
|
||||
continue;
|
||||
}
|
||||
// we don't allow transitive trust unless it is for ourself
|
||||
if (otherUserId != userId && otherUserId != client.userID) {
|
||||
continue;
|
||||
}
|
||||
for (final signatureEntry in signatureEntries.value.entries) {
|
||||
final fullKeyId = signatureEntry.key;
|
||||
final signature = signatureEntry.value;
|
||||
final keyId = fullKeyId.substring('ed25519:'.length);
|
||||
// we ignore self-signatures here
|
||||
if (otherUserId == userId && keyId == identifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final key = client.userDeviceKeys[otherUserId]?.deviceKeys[keyId] ??
|
||||
client.userDeviceKeys[otherUserId]?.crossSigningKeys[keyId];
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (onlyValidateUserIds_.isNotEmpty &&
|
||||
!onlyValidateUserIds_.contains(key.userId)) {
|
||||
// we don't want to verify keys from this user
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.blocked) {
|
||||
continue; // we can't be bothered about this keys signatures
|
||||
}
|
||||
var haveValidSignature = false;
|
||||
var gotSignatureFromCache = false;
|
||||
final fullKeyIdBool = validSignatures
|
||||
?.tryGetMap<String, dynamic>(otherUserId)
|
||||
?.tryGet<bool>(fullKeyId);
|
||||
if (fullKeyIdBool == true) {
|
||||
haveValidSignature = true;
|
||||
gotSignatureFromCache = true;
|
||||
} else if (fullKeyIdBool == false) {
|
||||
haveValidSignature = false;
|
||||
gotSignatureFromCache = true;
|
||||
}
|
||||
|
||||
if (!gotSignatureFromCache && key.ed25519Key != null) {
|
||||
// validate the signature manually
|
||||
haveValidSignature = _verifySignature(key.ed25519Key!, signature);
|
||||
final validSignatures = this.validSignatures ??= <String, dynamic>{};
|
||||
if (!validSignatures.containsKey(otherUserId)) {
|
||||
validSignatures[otherUserId] = <String, dynamic>{};
|
||||
}
|
||||
validSignatures[otherUserId][fullKeyId] = haveValidSignature;
|
||||
}
|
||||
if (!haveValidSignature) {
|
||||
// no valid signature, this key is useless
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((verifiedOnly && key.directVerified) ||
|
||||
(key is CrossSigningKey &&
|
||||
key.usage.contains('master') &&
|
||||
key.directVerified &&
|
||||
key.userId == client.userID)) {
|
||||
return true; // we verified this key and it is valid...all checks out!
|
||||
}
|
||||
// or else we just recurse into that key and chack if it works out
|
||||
final haveChain = key.hasValidSignatureChain(
|
||||
verifiedOnly: verifiedOnly,
|
||||
visited: visited_,
|
||||
onlyValidateUserIds: onlyValidateUserIds);
|
||||
if (haveChain) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> setVerified(bool newVerified, [bool sign = true]) async {
|
||||
_verified = newVerified;
|
||||
final encryption = client.encryption;
|
||||
if (newVerified &&
|
||||
sign &&
|
||||
encryption != null &&
|
||||
client.encryptionEnabled &&
|
||||
encryption.crossSigning.signable([this])) {
|
||||
// sign the key!
|
||||
// ignore: unawaited_futures
|
||||
encryption.crossSigning.sign([this]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setBlocked(bool newBlocked);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final data = super.toJson().copy();
|
||||
// some old data may have the verified and blocked keys which are unneeded now
|
||||
data.remove('verified');
|
||||
data.remove('blocked');
|
||||
return data;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => json.encode(toJson());
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) => (other is SignableKey &&
|
||||
other.userId == userId &&
|
||||
other.identifier == identifier);
|
||||
}
|
||||
|
||||
class CrossSigningKey extends SignableKey {
|
||||
@override
|
||||
String? identifier;
|
||||
|
||||
String? get publicKey => identifier;
|
||||
late List<String> usage;
|
||||
|
||||
bool get isValid =>
|
||||
userId.isNotEmpty &&
|
||||
publicKey != null &&
|
||||
keys.isNotEmpty &&
|
||||
ed25519Key != null;
|
||||
|
||||
@override
|
||||
Future<void> setVerified(bool newVerified, [bool sign = true]) async {
|
||||
if (!isValid) {
|
||||
throw Exception('setVerified called on invalid key');
|
||||
}
|
||||
await super.setVerified(newVerified, sign);
|
||||
await client.database
|
||||
?.setVerifiedUserCrossSigningKey(newVerified, userId, publicKey!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setBlocked(bool newBlocked) async {
|
||||
if (!isValid) {
|
||||
throw Exception('setBlocked called on invalid key');
|
||||
}
|
||||
_blocked = newBlocked;
|
||||
await client.database
|
||||
?.setBlockedUserCrossSigningKey(newBlocked, userId, publicKey!);
|
||||
}
|
||||
|
||||
CrossSigningKey.fromMatrixCrossSigningKey(MatrixCrossSigningKey k, Client cl)
|
||||
: super.fromJson(k.toJson().copy(), cl) {
|
||||
final json = toJson();
|
||||
identifier = k.publicKey;
|
||||
usage = json['usage'].cast<String>();
|
||||
}
|
||||
|
||||
CrossSigningKey.fromDbJson(Map<String, dynamic> dbEntry, Client cl)
|
||||
: super.fromJson(Event.getMapFromPayload(dbEntry['content']), cl) {
|
||||
final json = toJson();
|
||||
identifier = dbEntry['public_key'];
|
||||
usage = json['usage'].cast<String>();
|
||||
_verified = dbEntry['verified'];
|
||||
_blocked = dbEntry['blocked'];
|
||||
}
|
||||
|
||||
CrossSigningKey.fromJson(Map<String, dynamic> json, Client cl)
|
||||
: super.fromJson(json.copy(), cl) {
|
||||
final json = toJson();
|
||||
usage = json['usage'].cast<String>();
|
||||
if (keys.isNotEmpty) {
|
||||
identifier = keys.values.first;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceKeys extends SignableKey {
|
||||
@override
|
||||
String? identifier;
|
||||
|
||||
String? get deviceId => identifier;
|
||||
late List<String> algorithms;
|
||||
late DateTime lastActive;
|
||||
|
||||
String? get curve25519Key => keys['curve25519:$deviceId'];
|
||||
String? get deviceDisplayName => unsigned?['device_display_name'];
|
||||
|
||||
bool? _validSelfSignature;
|
||||
bool get selfSigned =>
|
||||
_validSelfSignature ??
|
||||
(_validSelfSignature = (deviceId != null &&
|
||||
signatures
|
||||
?.tryGetMap<String, dynamic>(userId)
|
||||
?.tryGet<String>('ed25519:$deviceId') ==
|
||||
null
|
||||
? false
|
||||
// without libolm we still want to be able to add devices. In that case we ofc just can't
|
||||
// verify the signature
|
||||
: _verifySignature(
|
||||
ed25519Key!, signatures![userId]!['ed25519:$deviceId']!,
|
||||
isSignatureWithoutLibolmValid: true)));
|
||||
|
||||
@override
|
||||
bool get blocked => super.blocked || !selfSigned;
|
||||
|
||||
bool get isValid =>
|
||||
deviceId != null &&
|
||||
keys.isNotEmpty &&
|
||||
curve25519Key != null &&
|
||||
ed25519Key != null &&
|
||||
selfSigned;
|
||||
|
||||
@override
|
||||
Future<void> setVerified(bool newVerified, [bool sign = true]) async {
|
||||
if (!isValid) {
|
||||
//throw Exception('setVerified called on invalid key');
|
||||
return;
|
||||
}
|
||||
await super.setVerified(newVerified, sign);
|
||||
await client.database
|
||||
?.setVerifiedUserDeviceKey(newVerified, userId, deviceId!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setBlocked(bool newBlocked) async {
|
||||
if (!isValid) {
|
||||
//throw Exception('setBlocked called on invalid key');
|
||||
return;
|
||||
}
|
||||
_blocked = newBlocked;
|
||||
await client.database
|
||||
?.setBlockedUserDeviceKey(newBlocked, userId, deviceId!);
|
||||
}
|
||||
|
||||
DeviceKeys.fromMatrixDeviceKeys(MatrixDeviceKeys k, Client cl,
|
||||
[DateTime? lastActiveTs])
|
||||
: super.fromJson(k.toJson().copy(), cl) {
|
||||
final json = toJson();
|
||||
identifier = k.deviceId;
|
||||
algorithms = json['algorithms'].cast<String>();
|
||||
lastActive = lastActiveTs ?? DateTime.now();
|
||||
}
|
||||
|
||||
DeviceKeys.fromDb(Map<String, dynamic> dbEntry, Client cl)
|
||||
: super.fromJson(Event.getMapFromPayload(dbEntry['content']), cl) {
|
||||
final json = toJson();
|
||||
identifier = dbEntry['device_id'];
|
||||
algorithms = json['algorithms'].cast<String>();
|
||||
_verified = dbEntry['verified'];
|
||||
_blocked = dbEntry['blocked'];
|
||||
lastActive =
|
||||
DateTime.fromMillisecondsSinceEpoch(dbEntry['last_active'] ?? 0);
|
||||
}
|
||||
|
||||
DeviceKeys.fromJson(Map<String, dynamic> json, Client cl)
|
||||
: super.fromJson(json.copy(), cl) {
|
||||
final json = toJson();
|
||||
identifier = json['device_id'];
|
||||
algorithms = json['algorithms'].cast<String>();
|
||||
lastActive = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
|
||||
KeyVerification startVerification() {
|
||||
if (!isValid) {
|
||||
throw Exception('setVerification called on invalid key');
|
||||
}
|
||||
final encryption = client.encryption;
|
||||
if (encryption == null) {
|
||||
throw Exception('setVerification called with disabled encryption');
|
||||
}
|
||||
|
||||
final request = KeyVerification(
|
||||
encryption: encryption, userId: userId, deviceId: deviceId!);
|
||||
|
||||
request.start();
|
||||
encryption.keyVerificationManager.addRequest(request);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
224
lib/src/utils/event_localizations.dart
Normal file
224
lib/src/utils/event_localizations.dart
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* 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 'package:collection/collection.dart';
|
||||
|
||||
import '../../encryption.dart';
|
||||
import '../../matrix.dart';
|
||||
|
||||
abstract class EventLocalizations {
|
||||
// As we need to create the localized body off of a different set of parameters, we
|
||||
// might create it with `event.plaintextBody`, maybe with `event.body`, maybe with the
|
||||
// reply fallback stripped, and maybe with the new body in `event.content['m.new_content']`.
|
||||
// Thus, it seems easier to offload that logic into `Event.getLocalizedBody()` and pass the
|
||||
// `body` variable around here.
|
||||
static String _localizedBodyNormalMessage(
|
||||
Event event, MatrixLocalizations i18n, String body) {
|
||||
switch (event.messageType) {
|
||||
case MessageTypes.Image:
|
||||
return i18n.sentAPicture(event.sender.calcDisplayname());
|
||||
case MessageTypes.File:
|
||||
return i18n.sentAFile(event.sender.calcDisplayname());
|
||||
case MessageTypes.Audio:
|
||||
return i18n.sentAnAudio(event.sender.calcDisplayname());
|
||||
case MessageTypes.Video:
|
||||
return i18n.sentAVideo(event.sender.calcDisplayname());
|
||||
case MessageTypes.Location:
|
||||
return i18n.sharedTheLocation(event.sender.calcDisplayname());
|
||||
case MessageTypes.Sticker:
|
||||
return i18n.sentASticker(event.sender.calcDisplayname());
|
||||
case MessageTypes.Emote:
|
||||
return '* $body';
|
||||
case MessageTypes.BadEncrypted:
|
||||
String errorText;
|
||||
switch (event.body) {
|
||||
case DecryptException.channelCorrupted:
|
||||
errorText = i18n.channelCorruptedDecryptError + '.';
|
||||
break;
|
||||
case DecryptException.notEnabled:
|
||||
errorText = i18n.encryptionNotEnabled + '.';
|
||||
break;
|
||||
case DecryptException.unknownAlgorithm:
|
||||
errorText = i18n.unknownEncryptionAlgorithm + '.';
|
||||
break;
|
||||
case DecryptException.unknownSession:
|
||||
errorText = i18n.noPermission + '.';
|
||||
break;
|
||||
default:
|
||||
errorText = body;
|
||||
break;
|
||||
}
|
||||
return i18n.couldNotDecryptMessage(errorText);
|
||||
case MessageTypes.Text:
|
||||
case MessageTypes.Notice:
|
||||
case MessageTypes.None:
|
||||
default:
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
// This map holds how to localize event types, and thus which event types exist.
|
||||
// If an event exists but it does not have a localized body, set its callback to null
|
||||
static final Map<String,
|
||||
String Function(Event event, MatrixLocalizations i18n, String body)?>
|
||||
localizationsMap = {
|
||||
EventTypes.Sticker: (event, i18n, body) =>
|
||||
i18n.sentASticker(event.sender.calcDisplayname()),
|
||||
EventTypes.Redaction: (event, i18n, body) =>
|
||||
i18n.redactedAnEvent(event.sender.calcDisplayname()),
|
||||
EventTypes.RoomAliases: (event, i18n, body) =>
|
||||
i18n.changedTheRoomAliases(event.sender.calcDisplayname()),
|
||||
EventTypes.RoomCanonicalAlias: (event, i18n, body) =>
|
||||
i18n.changedTheRoomInvitationLink(event.sender.calcDisplayname()),
|
||||
EventTypes.RoomCreate: (event, i18n, body) =>
|
||||
i18n.createdTheChat(event.sender.calcDisplayname()),
|
||||
EventTypes.RoomTombstone: (event, i18n, body) => i18n.roomHasBeenUpgraded,
|
||||
EventTypes.RoomJoinRules: (event, i18n, body) {
|
||||
final joinRules = JoinRules.values.firstWhereOrNull((r) =>
|
||||
r.toString().replaceAll('JoinRules.', '') ==
|
||||
event.content['join_rule']);
|
||||
if (joinRules == null) {
|
||||
return i18n.changedTheJoinRules(event.sender.calcDisplayname());
|
||||
} else {
|
||||
return i18n.changedTheJoinRulesTo(
|
||||
event.sender.calcDisplayname(), joinRules.getLocalizedString(i18n));
|
||||
}
|
||||
},
|
||||
EventTypes.RoomMember: (event, i18n, body) {
|
||||
var text = 'Failed to parse member event';
|
||||
final targetName = event.stateKeyUser?.calcDisplayname() ?? '';
|
||||
// Has the membership changed?
|
||||
final newMembership = event.content['membership'] ?? '';
|
||||
final oldMembership = event.prevContent?['membership'] ?? '';
|
||||
|
||||
if (newMembership != oldMembership) {
|
||||
if (oldMembership == 'invite' && newMembership == 'join') {
|
||||
text = i18n.acceptedTheInvitation(targetName);
|
||||
} else if (oldMembership == 'invite' && newMembership == 'leave') {
|
||||
if (event.stateKey == event.senderId) {
|
||||
text = i18n.rejectedTheInvitation(targetName);
|
||||
} else {
|
||||
text = i18n.hasWithdrawnTheInvitationFor(
|
||||
event.sender.calcDisplayname(), targetName);
|
||||
}
|
||||
} else if (oldMembership == 'leave' && newMembership == 'join') {
|
||||
text = i18n.joinedTheChat(targetName);
|
||||
} else if (oldMembership == 'join' && newMembership == 'ban') {
|
||||
text =
|
||||
i18n.kickedAndBanned(event.sender.calcDisplayname(), targetName);
|
||||
} else if (oldMembership == 'join' &&
|
||||
newMembership == 'leave' &&
|
||||
event.stateKey != event.senderId) {
|
||||
text = i18n.kicked(event.sender.calcDisplayname(), targetName);
|
||||
} else if (oldMembership == 'join' &&
|
||||
newMembership == 'leave' &&
|
||||
event.stateKey == event.senderId) {
|
||||
text = i18n.userLeftTheChat(targetName);
|
||||
} else if (oldMembership == 'invite' && newMembership == 'ban') {
|
||||
text = i18n.bannedUser(event.sender.calcDisplayname(), targetName);
|
||||
} else if (oldMembership == 'leave' && newMembership == 'ban') {
|
||||
text = i18n.bannedUser(event.sender.calcDisplayname(), targetName);
|
||||
} else if (oldMembership == 'ban' && newMembership == 'leave') {
|
||||
text = i18n.unbannedUser(event.sender.calcDisplayname(), targetName);
|
||||
} else if (newMembership == 'invite') {
|
||||
text = i18n.invitedUser(event.sender.calcDisplayname(), targetName);
|
||||
} else if (newMembership == 'join') {
|
||||
text = i18n.joinedTheChat(targetName);
|
||||
}
|
||||
} else if (newMembership == 'join') {
|
||||
final newAvatar = event.content.tryGet<String>('avatar_url') ?? '';
|
||||
final oldAvatar = event.prevContent?.tryGet<String>('avatar_url') ?? '';
|
||||
|
||||
final newDisplayname =
|
||||
event.content.tryGet<String>('displayname') ?? '';
|
||||
final oldDisplayname =
|
||||
event.prevContent?.tryGet<String>('displayname') ?? '';
|
||||
final stateKey = event.stateKey;
|
||||
|
||||
// Has the user avatar changed?
|
||||
if (newAvatar != oldAvatar) {
|
||||
text = i18n.changedTheProfileAvatar(targetName);
|
||||
}
|
||||
// Has the user displayname changed?
|
||||
else if (newDisplayname != oldDisplayname && stateKey != null) {
|
||||
text = i18n.changedTheDisplaynameTo(oldDisplayname, newDisplayname);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
},
|
||||
EventTypes.RoomPowerLevels: (event, i18n, body) =>
|
||||
i18n.changedTheChatPermissions(event.sender.calcDisplayname()),
|
||||
EventTypes.RoomName: (event, i18n, body) => i18n.changedTheChatNameTo(
|
||||
event.sender.calcDisplayname(), event.content['name']),
|
||||
EventTypes.RoomTopic: (event, i18n, body) =>
|
||||
i18n.changedTheChatDescriptionTo(
|
||||
event.sender.calcDisplayname(), event.content['topic']),
|
||||
EventTypes.RoomAvatar: (event, i18n, body) =>
|
||||
i18n.changedTheChatAvatar(event.sender.calcDisplayname()),
|
||||
EventTypes.GuestAccess: (event, i18n, body) {
|
||||
final guestAccess = GuestAccess.values.firstWhereOrNull((r) =>
|
||||
r.toString().replaceAll('GuestAccess.', '') ==
|
||||
event.content['guest_access']);
|
||||
if (guestAccess == null) {
|
||||
return i18n.changedTheGuestAccessRules(event.sender.calcDisplayname());
|
||||
} else {
|
||||
return i18n.changedTheGuestAccessRulesTo(event.sender.calcDisplayname(),
|
||||
guestAccess.getLocalizedString(i18n));
|
||||
}
|
||||
},
|
||||
EventTypes.HistoryVisibility: (event, i18n, body) {
|
||||
final historyVisibility = HistoryVisibility.values.firstWhereOrNull((r) =>
|
||||
r.toString().replaceAll('HistoryVisibility.', '') ==
|
||||
event.content['history_visibility']);
|
||||
if (historyVisibility == null) {
|
||||
return i18n.changedTheHistoryVisibility(event.sender.calcDisplayname());
|
||||
} else {
|
||||
return i18n.changedTheHistoryVisibilityTo(
|
||||
event.sender.calcDisplayname(),
|
||||
historyVisibility.getLocalizedString(i18n));
|
||||
}
|
||||
},
|
||||
EventTypes.Encryption: (event, i18n, body) {
|
||||
var localizedBody =
|
||||
i18n.activatedEndToEndEncryption(event.sender.calcDisplayname());
|
||||
if (event.room.client.encryptionEnabled == false) {
|
||||
localizedBody += '. ' + i18n.needPantalaimonWarning;
|
||||
}
|
||||
return localizedBody;
|
||||
},
|
||||
EventTypes.CallAnswer: (event, i18n, body) =>
|
||||
i18n.answeredTheCall(event.sender.calcDisplayname()),
|
||||
EventTypes.CallHangup: (event, i18n, body) =>
|
||||
i18n.endedTheCall(event.sender.calcDisplayname()),
|
||||
EventTypes.CallInvite: (event, i18n, body) =>
|
||||
i18n.startedACall(event.sender.calcDisplayname()),
|
||||
EventTypes.CallCandidates: (event, i18n, body) =>
|
||||
i18n.sentCallInformations(event.sender.calcDisplayname()),
|
||||
EventTypes.Encrypted: (event, i18n, body) =>
|
||||
_localizedBodyNormalMessage(event, i18n, body),
|
||||
EventTypes.Message: (event, i18n, body) =>
|
||||
_localizedBodyNormalMessage(event, i18n, body),
|
||||
EventTypes.Reaction: (event, i18n, body) => i18n.sentReaction(
|
||||
event.sender.calcDisplayname(),
|
||||
event.content
|
||||
.tryGetMap<String, dynamic>('m.relates_to')
|
||||
?.tryGet<String>('key') ??
|
||||
body,
|
||||
),
|
||||
};
|
||||
}
|
||||
72
lib/src/utils/event_update.dart
Normal file
72
lib/src/utils/event_update.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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 '../../matrix.dart';
|
||||
|
||||
enum EventUpdateType {
|
||||
timeline,
|
||||
state,
|
||||
history,
|
||||
accountData,
|
||||
ephemeral,
|
||||
inviteState
|
||||
}
|
||||
|
||||
/// Represents a new event (e.g. a message in a room) or an update for an
|
||||
/// already known event.
|
||||
class EventUpdate {
|
||||
/// Usually 'timeline', 'state' or whatever.
|
||||
final EventUpdateType type;
|
||||
|
||||
/// Most events belong to a room. If not, this equals to eventType.
|
||||
final String roomID;
|
||||
|
||||
@Deprecated("Use `content['type']` instead.")
|
||||
String get eventType => content['type'];
|
||||
|
||||
// The json payload of the content of this event.
|
||||
final Map<String, dynamic> content;
|
||||
|
||||
EventUpdate({
|
||||
required this.roomID,
|
||||
required this.type,
|
||||
required this.content,
|
||||
});
|
||||
|
||||
Future<EventUpdate> decrypt(Room room, {bool store = false}) async {
|
||||
final encryption = room.client.encryption;
|
||||
if (content['type'] != EventTypes.Encrypted ||
|
||||
!room.client.encryptionEnabled ||
|
||||
encryption == null) {
|
||||
return this;
|
||||
}
|
||||
try {
|
||||
final decrpytedEvent = await encryption.decryptRoomEvent(
|
||||
room.id, Event.fromJson(content, room),
|
||||
store: store, updateType: type);
|
||||
return EventUpdate(
|
||||
roomID: roomID,
|
||||
type: type,
|
||||
content: decrpytedEvent.toJson(),
|
||||
);
|
||||
} catch (e, s) {
|
||||
Logs().e('[LibOlm] Could not decrypt megolm event', e, s);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
259
lib/src/utils/html_to_text.dart
Normal file
259
lib/src/utils/html_to_text.dart
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 'package:collection/collection.dart';
|
||||
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html_unescape/html_unescape.dart';
|
||||
|
||||
class HtmlToText {
|
||||
/// Convert an HTML string to a pseudo-markdown plain text representation, with
|
||||
/// `data-mx-spoiler` spans redacted
|
||||
static String convert(String html) {
|
||||
// riot-web is notorious for creating bad reply fallback events from invalid messages which, if
|
||||
// not handled properly, can lead to impersonation. As such, we strip the entire `<mx-reply>` tags
|
||||
// here already, to prevent that from happening.
|
||||
// We do *not* do this in an AST and just with simple regex here, as riot-web tends to create
|
||||
// miss-matching tags, and this way we actually correctly identify what we want to strip and, well,
|
||||
// strip it.
|
||||
final renderHtml = html.replaceAll(
|
||||
RegExp('<mx-reply>.*<\/mx-reply>',
|
||||
caseSensitive: false, multiLine: false, dotAll: true),
|
||||
'');
|
||||
|
||||
final opts = _ConvertOpts();
|
||||
var reply = _walkNode(opts, parseFragment(renderHtml));
|
||||
reply = reply.replaceAll(RegExp(r'\s*$', multiLine: false), '');
|
||||
return reply;
|
||||
}
|
||||
|
||||
static String _parsePreContent(_ConvertOpts opts, Element node) {
|
||||
var text = node.innerHtml;
|
||||
final match =
|
||||
RegExp(r'^<code([^>]*)>', multiLine: false, caseSensitive: false)
|
||||
.firstMatch(text);
|
||||
if (match == null) {
|
||||
text = HtmlUnescape().convert(text);
|
||||
if (text.isNotEmpty) {
|
||||
if (text[0] != '\n') {
|
||||
text = '\n$text';
|
||||
}
|
||||
if (text[text.length - 1] != '\n') {
|
||||
text += '\n';
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
// remove <code> opening tag
|
||||
text = text.substring(match.end);
|
||||
// remove the </code> closing tag
|
||||
text = text.replaceAll(
|
||||
RegExp(r'</code>$', multiLine: false, caseSensitive: false), '');
|
||||
text = HtmlUnescape().convert(text);
|
||||
if (text.isNotEmpty) {
|
||||
if (text[0] != '\n') {
|
||||
text = '\n$text';
|
||||
}
|
||||
if (text[text.length - 1] != '\n') {
|
||||
text += '\n';
|
||||
}
|
||||
}
|
||||
final language =
|
||||
RegExp(r'language-(\w+)', multiLine: false, caseSensitive: false)
|
||||
.firstMatch(match.group(1)!);
|
||||
if (language != null) {
|
||||
text = language.group(1)! + text;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
static String _parseBlockquoteContent(_ConvertOpts opts, Element node) {
|
||||
final msg = _walkChildNodes(opts, node);
|
||||
return msg.split('\n').map((s) => '> $s').join('\n') + '\n';
|
||||
}
|
||||
|
||||
static String _parseSpanContent(_ConvertOpts opts, Element node) {
|
||||
final content = _walkChildNodes(opts, node);
|
||||
if (node.attributes['data-mx-spoiler'] is String) {
|
||||
var spoiler = '█' * content.length;
|
||||
final reason = node.attributes['data-mx-spoiler'];
|
||||
if (reason != '') {
|
||||
spoiler = '($reason) $spoiler';
|
||||
}
|
||||
return spoiler;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
static String _parseUlContent(_ConvertOpts opts, Element node) {
|
||||
opts.listDepth++;
|
||||
final entries = _listChildNodes(opts, node, {'li'});
|
||||
opts.listDepth--;
|
||||
final bulletPoint =
|
||||
_listBulletPoints[opts.listDepth % _listBulletPoints.length];
|
||||
|
||||
return entries
|
||||
.map((s) =>
|
||||
(' ' * opts.listDepth) +
|
||||
bulletPoint +
|
||||
' ' +
|
||||
s.replaceAll('\n', '\n' + (' ' * opts.listDepth) + ' '))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
static String _parseOlContent(_ConvertOpts opts, Element node) {
|
||||
opts.listDepth++;
|
||||
final entries = _listChildNodes(opts, node, {'li'});
|
||||
opts.listDepth--;
|
||||
final startStr = node.attributes['start'];
|
||||
final start = (startStr is String &&
|
||||
RegExp(r'^[0-9]+$', multiLine: false).hasMatch(startStr))
|
||||
? int.parse(startStr)
|
||||
: 1;
|
||||
|
||||
return entries
|
||||
.mapIndexed((index, s) =>
|
||||
(' ' * opts.listDepth) +
|
||||
'${start + index}. ' +
|
||||
s.replaceAll('\n', '\n' + (' ' * opts.listDepth) + ' '))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
static const _listBulletPoints = <String>['●', '○', '■', '‣'];
|
||||
|
||||
static List<String> _listChildNodes(_ConvertOpts opts, Element node,
|
||||
[Iterable<String>? types]) {
|
||||
final replies = <String>[];
|
||||
for (final child in node.nodes) {
|
||||
if (types != null &&
|
||||
types.isNotEmpty &&
|
||||
((child is Text) ||
|
||||
((child is Element) &&
|
||||
!types.contains(child.localName!.toLowerCase())))) {
|
||||
continue;
|
||||
}
|
||||
replies.add(_walkNode(opts, child));
|
||||
}
|
||||
return replies;
|
||||
}
|
||||
|
||||
static const _blockTags = <String>{
|
||||
'blockquote',
|
||||
'ul',
|
||||
'ol',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'pre',
|
||||
};
|
||||
|
||||
static String _walkChildNodes(_ConvertOpts opts, Node node) {
|
||||
var reply = '';
|
||||
var lastTag = '';
|
||||
for (final child in node.nodes) {
|
||||
final thisTag = child is Element ? child.localName!.toLowerCase() : '';
|
||||
if (thisTag == 'p' && lastTag == 'p') {
|
||||
reply += '\n\n';
|
||||
} else if (_blockTags.contains(thisTag) &&
|
||||
reply.isNotEmpty &&
|
||||
reply[reply.length - 1] != '\n') {
|
||||
reply += '\n';
|
||||
}
|
||||
reply += _walkNode(opts, child);
|
||||
if (thisTag.isNotEmpty) {
|
||||
lastTag = thisTag;
|
||||
}
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
static String _walkNode(_ConvertOpts opts, Node node) {
|
||||
if (node is Text) {
|
||||
// ignore \n between single nodes
|
||||
return node.text == '\n' ? '' : node.text;
|
||||
} else if (node is Element) {
|
||||
final tag = node.localName!.toLowerCase();
|
||||
switch (tag) {
|
||||
case 'em':
|
||||
case 'i':
|
||||
return '*${_walkChildNodes(opts, node)}*';
|
||||
case 'strong':
|
||||
case 'b':
|
||||
return '**${_walkChildNodes(opts, node)}**';
|
||||
case 'u':
|
||||
case 'ins':
|
||||
return '__${_walkChildNodes(opts, node)}__';
|
||||
case 'del':
|
||||
case 'strike':
|
||||
case 's':
|
||||
return '~~${_walkChildNodes(opts, node)}~~';
|
||||
case 'code':
|
||||
return '`${node.text}`';
|
||||
case 'pre':
|
||||
return '```${_parsePreContent(opts, node)}```\n';
|
||||
case 'a':
|
||||
final href = node.attributes['href'] ?? '';
|
||||
final content = _walkChildNodes(opts, node);
|
||||
if (href.toLowerCase().startsWith('https://matrix.to/#/') ||
|
||||
href.toLowerCase().startsWith('matrix:')) {
|
||||
return content;
|
||||
}
|
||||
return '🔗$content';
|
||||
case 'img':
|
||||
return node.attributes['alt'] ??
|
||||
node.attributes['title'] ??
|
||||
node.attributes['src'] ??
|
||||
'';
|
||||
case 'br':
|
||||
return '\n';
|
||||
case 'blockquote':
|
||||
return _parseBlockquoteContent(opts, node);
|
||||
case 'ul':
|
||||
return _parseUlContent(opts, node);
|
||||
case 'ol':
|
||||
return _parseOlContent(opts, node);
|
||||
case 'mx-reply':
|
||||
return '';
|
||||
case 'hr':
|
||||
return '\n----------\n';
|
||||
case 'h1':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6':
|
||||
final mark = '#' * int.parse(tag[1]);
|
||||
return '$mark ${_walkChildNodes(opts, node)}\n';
|
||||
case 'span':
|
||||
return _parseSpanContent(opts, node);
|
||||
default:
|
||||
return _walkChildNodes(opts, node);
|
||||
}
|
||||
} else {
|
||||
return _walkChildNodes(opts, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ConvertOpts {
|
||||
int listDepth = 0;
|
||||
}
|
||||
115
lib/src/utils/http_timeout.dart
Normal file
115
lib/src/utils/http_timeout.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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:async';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
/// Stream.timeout fails if no progress is made in timeLimit.
|
||||
/// In contrast, streamTotalTimeout fails if the stream isn't completed
|
||||
/// until timeoutFuture.
|
||||
Stream<T> streamTotalTimeout<T>(
|
||||
Stream<T> stream, Future<Never> timeoutFuture) async* {
|
||||
final si = StreamIterator(stream);
|
||||
while (await Future.any([si.moveNext(), timeoutFuture])) {
|
||||
yield si.current;
|
||||
}
|
||||
}
|
||||
|
||||
http.StreamedResponse replaceStream(
|
||||
http.StreamedResponse base, Stream<List<int>> stream) =>
|
||||
http.StreamedResponse(
|
||||
http.ByteStream(stream),
|
||||
base.statusCode,
|
||||
contentLength: base.contentLength,
|
||||
request: base.request,
|
||||
headers: base.headers,
|
||||
isRedirect: base.isRedirect,
|
||||
persistentConnection: base.persistentConnection,
|
||||
reasonPhrase: base.reasonPhrase,
|
||||
);
|
||||
|
||||
/// Http Client that enforces a timeout on requests.
|
||||
/// Timeout calculation is done in a subclass.
|
||||
abstract class TimeoutHttpClient extends http.BaseClient {
|
||||
TimeoutHttpClient(this.inner);
|
||||
|
||||
http.Client inner;
|
||||
|
||||
Duration get timeout;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final timeoutFuture = Completer<Never>().future.timeout(timeout);
|
||||
final response = await Future.any([inner.send(request), timeoutFuture]);
|
||||
return replaceStream(
|
||||
response, streamTotalTimeout(response.stream, timeoutFuture));
|
||||
}
|
||||
}
|
||||
|
||||
class FixedTimeoutHttpClient extends TimeoutHttpClient {
|
||||
FixedTimeoutHttpClient(http.Client inner, this.timeout) : super(inner);
|
||||
@override
|
||||
Duration timeout;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) =>
|
||||
super.send(request);
|
||||
}
|
||||
|
||||
class VariableTimeoutHttpClient extends TimeoutHttpClient {
|
||||
/// Matrix synchronisation is done with https long polling. This needs a
|
||||
/// timeout which is usually 30 seconds.
|
||||
int syncTimeoutSec;
|
||||
|
||||
int _timeoutFactor = 1;
|
||||
|
||||
@override
|
||||
Duration get timeout =>
|
||||
Duration(seconds: _timeoutFactor * syncTimeoutSec + 5);
|
||||
|
||||
VariableTimeoutHttpClient(http.Client inner, [this.syncTimeoutSec = 30])
|
||||
: super(inner);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
try {
|
||||
final response = await super.send(request);
|
||||
return replaceStream(response, (() async* {
|
||||
try {
|
||||
await for (final chunk in response.stream) {
|
||||
yield chunk;
|
||||
}
|
||||
_timeoutFactor = 1;
|
||||
} on TimeoutException catch (e, s) {
|
||||
_timeoutFactor *= 2;
|
||||
throw MatrixConnectionException(e, s);
|
||||
} catch (e, s) {
|
||||
throw MatrixConnectionException(e, s);
|
||||
}
|
||||
})());
|
||||
} on TimeoutException catch (e, s) {
|
||||
_timeoutFactor *= 2;
|
||||
throw MatrixConnectionException(e, s);
|
||||
} catch (e, s) {
|
||||
throw MatrixConnectionException(e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
95
lib/src/utils/image_pack_extension.dart
Normal file
95
lib/src/utils/image_pack_extension.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 'package:slugify/slugify.dart';
|
||||
import 'package:matrix_api_lite/matrix_api_lite.dart';
|
||||
|
||||
import '../room.dart';
|
||||
|
||||
extension ImagePackRoomExtension on Room {
|
||||
/// Get all the active image packs for the specified [usage], mapped by their slug
|
||||
Map<String, ImagePackContent> getImagePacks([ImagePackUsage? usage]) {
|
||||
final allMxcs = <Uri>{}; // used for easy deduplication
|
||||
final packs = <String, ImagePackContent>{};
|
||||
final addImagePack = (BasicEvent? event, {Room? room, String? slug}) {
|
||||
if (event == null) return;
|
||||
final imagePack = event.parsedImagePackContent;
|
||||
final finalSlug = slugify(slug ?? 'pack');
|
||||
for (final entry in imagePack.images.entries) {
|
||||
final image = entry.value;
|
||||
if (allMxcs.contains(image.url)) {
|
||||
continue;
|
||||
}
|
||||
final imageUsage = image.usage ?? imagePack.pack.usage;
|
||||
if (usage != null &&
|
||||
imageUsage != null &&
|
||||
!imageUsage.contains(usage)) {
|
||||
continue;
|
||||
}
|
||||
packs
|
||||
.putIfAbsent(
|
||||
finalSlug,
|
||||
() => ImagePackContent.fromJson({})
|
||||
..pack.displayName = imagePack.pack.displayName ??
|
||||
room?.displayname ??
|
||||
finalSlug
|
||||
..pack.avatarUrl = imagePack.pack.avatarUrl ?? room?.avatar
|
||||
..pack.attribution = imagePack.pack.attribution)
|
||||
.images[entry.key] = image;
|
||||
allMxcs.add(image.url);
|
||||
}
|
||||
};
|
||||
// first we add the user image pack
|
||||
addImagePack(client.accountData['im.ponies.user_emotes'], slug: 'user');
|
||||
// next we add all the external image packs
|
||||
final packRooms = client.accountData['im.ponies.emote_rooms'];
|
||||
if (packRooms != null && packRooms.content['rooms'] is Map) {
|
||||
for (final roomEntry in packRooms.content['rooms'].entries) {
|
||||
final roomId = roomEntry.key;
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room != null && roomEntry.value is Map) {
|
||||
for (final stateKeyEntry in roomEntry.value.entries) {
|
||||
final stateKey = stateKeyEntry.key;
|
||||
final fallbackSlug =
|
||||
'${room.displayname}-${stateKey.isNotEmpty ? '$stateKey-' : ''}${room.id}';
|
||||
addImagePack(room.getState('im.ponies.room_emotes', stateKey),
|
||||
room: room, slug: fallbackSlug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// finally we add all of this rooms state
|
||||
final allRoomEmotes = states['im.ponies.room_emotes'];
|
||||
if (allRoomEmotes != null) {
|
||||
for (final entry in allRoomEmotes.entries) {
|
||||
addImagePack(entry.value,
|
||||
room: this,
|
||||
slug: (entry.value.stateKey?.isNotEmpty == true)
|
||||
? entry.value.stateKey
|
||||
: 'room');
|
||||
}
|
||||
}
|
||||
return packs;
|
||||
}
|
||||
|
||||
/// Get a flat view of all the image packs of a specified [usage], that is a map of all
|
||||
/// slugs to a map of the image code to their mxc url
|
||||
Map<String, Map<String, String>> getImagePacksFlat([ImagePackUsage? usage]) =>
|
||||
getImagePacks(usage).map((k, v) =>
|
||||
MapEntry(k, v.images.map((k, v) => MapEntry(k, v.url.toString()))));
|
||||
}
|
||||
33
lib/src/utils/map_copy_extension.dart
Normal file
33
lib/src/utils/map_copy_extension.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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/>.
|
||||
*/
|
||||
|
||||
extension MapCopyExtension on Map<String, dynamic> {
|
||||
/// Deep-copies a given json map
|
||||
Map<String, dynamic> copy() {
|
||||
final copy = Map<String, dynamic>.from(this);
|
||||
for (final entry in copy.entries) {
|
||||
if (entry.value is Map<String, dynamic>) {
|
||||
copy[entry.key] = (entry.value as Map<String, dynamic>).copy();
|
||||
}
|
||||
if (entry.value is List) {
|
||||
copy[entry.key] = List.from(entry.value);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
256
lib/src/utils/markdown.dart
Normal file
256
lib/src/utils/markdown.dart
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 'package:markdown/markdown.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
const htmlAttrEscape = HtmlEscape(HtmlEscapeMode.attribute);
|
||||
|
||||
class LinebreakSyntax extends InlineSyntax {
|
||||
LinebreakSyntax() : super(r'\n');
|
||||
|
||||
@override
|
||||
bool onMatch(InlineParser parser, Match match) {
|
||||
parser.addNode(Element.empty('br'));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class SpoilerSyntax extends TagSyntax {
|
||||
SpoilerSyntax() : super(r'\|\|', requiresDelimiterRun: true);
|
||||
|
||||
@override
|
||||
Node close(InlineParser parser, Delimiter opener, Delimiter closer,
|
||||
{required List<Node> Function() getChildren}) {
|
||||
final children = getChildren();
|
||||
final newChildren = <Node>[];
|
||||
var searchingForReason = true;
|
||||
var reason = '';
|
||||
for (final child in children) {
|
||||
// If we already found a reason, let's just use our child nodes as-is
|
||||
if (!searchingForReason) {
|
||||
newChildren.add(child);
|
||||
continue;
|
||||
}
|
||||
if (child is Text) {
|
||||
final ix = child.text.indexOf('|');
|
||||
if (ix > 0) {
|
||||
reason += child.text.substring(0, ix);
|
||||
newChildren.add(Text(child.text.substring(ix + 1)));
|
||||
searchingForReason = false;
|
||||
} else {
|
||||
reason += child.text;
|
||||
}
|
||||
} else {
|
||||
// if we don't have a text node as reason we just want to cancel this whole thing
|
||||
break;
|
||||
}
|
||||
}
|
||||
// if we were still searching for a reason that means there was none - use the original children!
|
||||
final element =
|
||||
Element('span', searchingForReason ? children : newChildren);
|
||||
element.attributes['data-mx-spoiler'] =
|
||||
searchingForReason ? '' : htmlAttrEscape.convert(reason);
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
class EmoteSyntax extends InlineSyntax {
|
||||
final Map<String, Map<String, String>> Function()? getEmotePacks;
|
||||
Map<String, Map<String, String>>? emotePacks;
|
||||
EmoteSyntax(this.getEmotePacks) : super(r':(?:([-\w]+)~)?([-\w]+):');
|
||||
|
||||
@override
|
||||
bool onMatch(InlineParser parser, Match match) {
|
||||
final emotePacks = this.emotePacks ??= getEmotePacks?.call() ?? {};
|
||||
final pack = match[1] ?? '';
|
||||
final emote = match[2];
|
||||
String? mxc;
|
||||
if (pack.isEmpty) {
|
||||
// search all packs
|
||||
for (final emotePack in emotePacks.values) {
|
||||
mxc = emotePack[emote];
|
||||
if (mxc != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mxc = emotePacks[pack]?[emote];
|
||||
}
|
||||
if (mxc == null) {
|
||||
// emote not found. Insert the whole thing as plain text
|
||||
parser.addNode(Text(match[0]!));
|
||||
return true;
|
||||
}
|
||||
final element = Element.empty('img');
|
||||
element.attributes['data-mx-emoticon'] = '';
|
||||
element.attributes['src'] = htmlAttrEscape.convert(mxc);
|
||||
element.attributes['alt'] = htmlAttrEscape.convert(':$emote:');
|
||||
element.attributes['title'] = htmlAttrEscape.convert(':$emote:');
|
||||
element.attributes['height'] = '32';
|
||||
element.attributes['vertical-align'] = 'middle';
|
||||
parser.addNode(element);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class InlineLatexSyntax extends TagSyntax {
|
||||
InlineLatexSyntax() : super(r'\$([^\s$]([^\$]*[^\s$])?)\$');
|
||||
|
||||
@override
|
||||
bool onMatch(InlineParser parser, Match match) {
|
||||
final element =
|
||||
Element('span', [Element.text('code', htmlEscape.convert(match[1]!))]);
|
||||
element.attributes['data-mx-maths'] = htmlAttrEscape.convert(match[1]!);
|
||||
parser.addNode(element);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// We also want to allow single-lines of like "$$latex$$"
|
||||
class BlockLatexSyntax extends BlockSyntax {
|
||||
@override
|
||||
RegExp get pattern => RegExp(r'^[ ]{0,3}\$\$(.*)$');
|
||||
|
||||
final endPattern = RegExp(r'^(.*)\$\$\s*$');
|
||||
|
||||
@override
|
||||
List<String> parseChildLines(BlockParser parser) {
|
||||
final childLines = <String>[];
|
||||
var first = true;
|
||||
while (!parser.isDone) {
|
||||
final match = endPattern.firstMatch(parser.current);
|
||||
if (match == null || (first && match[1]!.trim().isEmpty)) {
|
||||
childLines.add(parser.current);
|
||||
parser.advance();
|
||||
} else {
|
||||
childLines.add(match[1]!);
|
||||
parser.advance();
|
||||
break;
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
return childLines;
|
||||
}
|
||||
|
||||
@override
|
||||
Node parse(BlockParser parser) {
|
||||
final childLines = parseChildLines(parser);
|
||||
// we use .substring(2) as childLines will *always* contain the first two '$$'
|
||||
final latex = childLines.join('\n').trim().substring(2).trim();
|
||||
final element = Element('div', [
|
||||
Element('pre', [Element.text('code', htmlEscape.convert(latex))])
|
||||
]);
|
||||
element.attributes['data-mx-maths'] = htmlAttrEscape.convert(latex);
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
class PillSyntax extends InlineSyntax {
|
||||
PillSyntax()
|
||||
: super(
|
||||
r'([@#!][^\s:]*:(?:[^\s]+\.\w+|[\d\.]+|\[[a-fA-F0-9:]+\])(?::\d+)?)');
|
||||
|
||||
@override
|
||||
bool onMatch(InlineParser parser, Match match) {
|
||||
if (match.start > 0 &&
|
||||
!RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) {
|
||||
parser.addNode(Text(match[0]!));
|
||||
return true;
|
||||
}
|
||||
final identifier = match[1]!;
|
||||
final element = Element.text('a', htmlEscape.convert(identifier));
|
||||
element.attributes['href'] =
|
||||
htmlAttrEscape.convert('https://matrix.to/#/$identifier');
|
||||
parser.addNode(element);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class MentionSyntax extends InlineSyntax {
|
||||
final String? Function(String)? getMention;
|
||||
MentionSyntax(this.getMention) : super(r'(@(?:\[[^\]:]+\]|\w+)(?:#\w+)?)');
|
||||
|
||||
@override
|
||||
bool onMatch(InlineParser parser, Match match) {
|
||||
final mention = getMention?.call(match[1]!);
|
||||
if ((match.start > 0 &&
|
||||
!RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) ||
|
||||
mention == null) {
|
||||
parser.addNode(Text(match[0]!));
|
||||
return true;
|
||||
}
|
||||
final element = Element.text('a', htmlEscape.convert(match[1]!));
|
||||
element.attributes['href'] =
|
||||
htmlAttrEscape.convert('https://matrix.to/#/$mention');
|
||||
parser.addNode(element);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
String markdown(
|
||||
String text, {
|
||||
Map<String, Map<String, String>> Function()? getEmotePacks,
|
||||
String? Function(String)? getMention,
|
||||
}) {
|
||||
var ret = markdownToHtml(
|
||||
text,
|
||||
extensionSet: ExtensionSet.commonMark,
|
||||
blockSyntaxes: [
|
||||
BlockLatexSyntax(),
|
||||
],
|
||||
inlineSyntaxes: [
|
||||
StrikethroughSyntax(),
|
||||
LinebreakSyntax(),
|
||||
SpoilerSyntax(),
|
||||
EmoteSyntax(getEmotePacks),
|
||||
PillSyntax(),
|
||||
MentionSyntax(getMention),
|
||||
InlineLatexSyntax(),
|
||||
],
|
||||
);
|
||||
|
||||
var stripPTags = '<p>'.allMatches(ret).length <= 1;
|
||||
if (stripPTags) {
|
||||
const otherBlockTags = {
|
||||
'table',
|
||||
'pre',
|
||||
'ol',
|
||||
'ul',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'div',
|
||||
};
|
||||
for (final tag in otherBlockTags) {
|
||||
// we check for the close tag as the opening one might have attributes
|
||||
if (ret.contains('</$tag>')) {
|
||||
stripPTags = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stripPTags) {
|
||||
ret = ret.replaceAll('<p>', '').replaceAll('</p>', '');
|
||||
}
|
||||
return ret.trim().replaceAll(RegExp(r'(<br />)+$'), '');
|
||||
}
|
||||
34
lib/src/utils/marked_unread.dart
Normal file
34
lib/src/utils/marked_unread.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 'package:matrix_api_lite/src/utils/try_get_map_extension.dart';
|
||||
|
||||
mixin EventType {
|
||||
static const String markedUnread = 'com.famedly.marked_unread';
|
||||
}
|
||||
|
||||
class MarkedUnread {
|
||||
final bool unread;
|
||||
|
||||
const MarkedUnread(this.unread);
|
||||
|
||||
MarkedUnread.fromJson(Map<String, dynamic> json)
|
||||
: unread = json.tryGet<bool>('unread') ?? false;
|
||||
|
||||
Map<String, dynamic> toJson() => {'unread': unread};
|
||||
}
|
||||
290
lib/src/utils/matrix_file.dart
Normal file
290
lib/src/utils/matrix_file.dart
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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/>.
|
||||
*/
|
||||
|
||||
/// Workaround until [File] in dart:io and dart:html is unified
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:blurhash_dart/blurhash_dart.dart';
|
||||
import 'package:image/image.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
class MatrixFile {
|
||||
final Uint8List bytes;
|
||||
final String name;
|
||||
final String mimeType;
|
||||
|
||||
/// Encrypts this file and returns the
|
||||
/// encryption information as an [EncryptedFile].
|
||||
Future<EncryptedFile> encrypt() async {
|
||||
return await encryptFile(bytes);
|
||||
}
|
||||
|
||||
MatrixFile({required this.bytes, required String name, String? mimeType})
|
||||
: mimeType = mimeType ??
|
||||
lookupMimeType(name, headerBytes: bytes) ??
|
||||
'application/octet-stream',
|
||||
name = name.split('/').last.toLowerCase();
|
||||
|
||||
int get size => bytes.length;
|
||||
|
||||
String get msgType {
|
||||
if (mimeType.toLowerCase().startsWith('image/')) {
|
||||
return MessageTypes.Image;
|
||||
}
|
||||
if (mimeType.toLowerCase().startsWith('video/')) {
|
||||
return MessageTypes.Video;
|
||||
}
|
||||
if (mimeType.toLowerCase().startsWith('audio/')) {
|
||||
return MessageTypes.Audio;
|
||||
}
|
||||
return MessageTypes.File;
|
||||
}
|
||||
|
||||
Map<String, dynamic> get info => ({
|
||||
'mimetype': mimeType,
|
||||
'size': size,
|
||||
});
|
||||
}
|
||||
|
||||
class MatrixImageFile extends MatrixFile {
|
||||
MatrixImageFile({
|
||||
required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType,
|
||||
this.width,
|
||||
this.height,
|
||||
this.blurhash,
|
||||
}) : super(bytes: bytes, name: name, mimeType: mimeType);
|
||||
|
||||
/// Creates a new image file and calculates the width, height and blurhash.
|
||||
static Future<MatrixImageFile> create(
|
||||
{required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType,
|
||||
Future<T> Function<T, U>(FutureOr<T> Function(U arg) function, U arg)?
|
||||
compute}) async {
|
||||
final metaData = compute != null
|
||||
? await compute(_calcMetadata, bytes)
|
||||
: _calcMetadata(bytes);
|
||||
|
||||
return MatrixImageFile(
|
||||
bytes: metaData?.bytes ?? bytes,
|
||||
name: name,
|
||||
mimeType: mimeType,
|
||||
width: metaData?.width,
|
||||
height: metaData?.height,
|
||||
blurhash: metaData?.blurhash,
|
||||
);
|
||||
}
|
||||
|
||||
/// builds a [MatrixImageFile] and shrinks it in order to reduce traffic
|
||||
///
|
||||
/// in case shrinking does not work (e.g. for unsupported MIME types), the
|
||||
/// initial image is simply preserved
|
||||
static Future<MatrixImageFile> shrink(
|
||||
{required Uint8List bytes,
|
||||
required String name,
|
||||
int maxDimension = 1600,
|
||||
String? mimeType,
|
||||
Future<T> Function<T, U>(FutureOr<T> Function(U arg) function, U arg)?
|
||||
compute}) async {
|
||||
final arguments = _ResizeArguments(
|
||||
bytes: bytes,
|
||||
maxDimension: maxDimension,
|
||||
fileName: name,
|
||||
calcBlurhash: true,
|
||||
);
|
||||
final resizedData = compute != null
|
||||
? await compute(_resize, arguments)
|
||||
: _resize(arguments);
|
||||
|
||||
if (resizedData == null) {
|
||||
return MatrixImageFile(bytes: bytes, name: name, mimeType: mimeType);
|
||||
}
|
||||
|
||||
final thumbnailFile = MatrixImageFile(
|
||||
bytes: resizedData.bytes,
|
||||
name: name,
|
||||
mimeType: mimeType,
|
||||
width: resizedData.width,
|
||||
height: resizedData.height,
|
||||
blurhash: resizedData.blurhash,
|
||||
);
|
||||
return thumbnailFile;
|
||||
}
|
||||
|
||||
/// returns the width of the image
|
||||
final int? width;
|
||||
|
||||
/// returns the height of the image
|
||||
final int? height;
|
||||
|
||||
/// generates the blur hash for the image
|
||||
final String? blurhash;
|
||||
|
||||
@override
|
||||
String get msgType => 'm.image';
|
||||
@override
|
||||
Map<String, dynamic> get info => ({
|
||||
...super.info,
|
||||
if (width != null) 'w': width,
|
||||
if (height != null) 'h': height,
|
||||
if (blurhash != null) 'xyz.amorgan.blurhash': blurhash,
|
||||
});
|
||||
|
||||
/// computes a thumbnail for the image
|
||||
Future<MatrixImageFile?> generateThumbnail(
|
||||
{int dimension = Client.defaultThumbnailSize,
|
||||
Future<T> Function<T, U>(FutureOr<T> Function(U arg) function, U arg)?
|
||||
compute}) async {
|
||||
final thumbnailFile = await shrink(
|
||||
bytes: bytes,
|
||||
name: name,
|
||||
mimeType: mimeType,
|
||||
compute: compute,
|
||||
maxDimension: dimension,
|
||||
);
|
||||
// the thumbnail should rather return null than the unshrinked image
|
||||
if ((thumbnailFile.width ?? 0) > dimension ||
|
||||
(thumbnailFile.height ?? 0) > dimension) {
|
||||
return null;
|
||||
}
|
||||
return thumbnailFile;
|
||||
}
|
||||
|
||||
static _ResizedResponse? _calcMetadata(Uint8List bytes) {
|
||||
final image = decodeImage(bytes);
|
||||
if (image == null) return null;
|
||||
|
||||
return _ResizedResponse(
|
||||
bytes: bytes,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
blurhash: BlurHash.encode(
|
||||
image,
|
||||
numCompX: 4,
|
||||
numCompY: 3,
|
||||
).hash,
|
||||
);
|
||||
}
|
||||
|
||||
static _ResizedResponse? _resize(_ResizeArguments arguments) {
|
||||
final image = decodeImage(arguments.bytes);
|
||||
|
||||
final resized = copyResize(image!,
|
||||
height: image.height > image.width ? arguments.maxDimension : null,
|
||||
width: image.width >= image.height ? arguments.maxDimension : null);
|
||||
|
||||
final encoded = encodeNamedImage(resized, arguments.fileName);
|
||||
if (encoded == null) return null;
|
||||
final bytes = Uint8List.fromList(encoded);
|
||||
return _ResizedResponse(
|
||||
bytes: bytes,
|
||||
width: resized.width,
|
||||
height: resized.height,
|
||||
blurhash: arguments.calcBlurhash
|
||||
? BlurHash.encode(
|
||||
resized,
|
||||
numCompX: 4,
|
||||
numCompY: 3,
|
||||
).hash
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResizedResponse {
|
||||
final Uint8List bytes;
|
||||
final int width;
|
||||
final int height;
|
||||
final String? blurhash;
|
||||
|
||||
const _ResizedResponse({
|
||||
required this.bytes,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.blurhash,
|
||||
});
|
||||
}
|
||||
|
||||
class _ResizeArguments {
|
||||
final Uint8List bytes;
|
||||
final int maxDimension;
|
||||
final String fileName;
|
||||
final bool calcBlurhash;
|
||||
|
||||
const _ResizeArguments({
|
||||
required this.bytes,
|
||||
required this.maxDimension,
|
||||
required this.fileName,
|
||||
required this.calcBlurhash,
|
||||
});
|
||||
}
|
||||
|
||||
class MatrixVideoFile extends MatrixFile {
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? duration;
|
||||
|
||||
MatrixVideoFile(
|
||||
{required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType,
|
||||
this.width,
|
||||
this.height,
|
||||
this.duration})
|
||||
: super(bytes: bytes, name: name, mimeType: mimeType);
|
||||
@override
|
||||
String get msgType => 'm.video';
|
||||
@override
|
||||
Map<String, dynamic> get info => ({
|
||||
...super.info,
|
||||
if (width != null) 'w': width,
|
||||
if (height != null) 'h': height,
|
||||
if (duration != null) 'duration': duration,
|
||||
});
|
||||
}
|
||||
|
||||
class MatrixAudioFile extends MatrixFile {
|
||||
final int? duration;
|
||||
|
||||
MatrixAudioFile(
|
||||
{required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType,
|
||||
this.duration})
|
||||
: super(bytes: bytes, name: name, mimeType: mimeType);
|
||||
@override
|
||||
String get msgType => 'm.audio';
|
||||
@override
|
||||
Map<String, dynamic> get info => ({
|
||||
...super.info,
|
||||
if (duration != null) 'duration': duration,
|
||||
});
|
||||
}
|
||||
|
||||
extension ToMatrixFile on EncryptedFile {
|
||||
MatrixFile toMatrixFile() {
|
||||
return MatrixFile(
|
||||
bytes: data, name: 'crypt', mimeType: 'application/octet-stream');
|
||||
}
|
||||
}
|
||||
136
lib/src/utils/matrix_id_string_extension.dart
Normal file
136
lib/src/utils/matrix_id_string_extension.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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/>.
|
||||
*/
|
||||
|
||||
const Set<String> validSigils = {'@', '!', '#', '\$', '+'};
|
||||
|
||||
const int maxLength = 255;
|
||||
|
||||
extension MatrixIdExtension on String {
|
||||
List<String> _getParts() {
|
||||
final s = substring(1);
|
||||
final ix = s.indexOf(':');
|
||||
if (ix == -1) {
|
||||
return [substring(1)];
|
||||
}
|
||||
return [s.substring(0, ix), s.substring(ix + 1)];
|
||||
}
|
||||
|
||||
bool get isValidMatrixId {
|
||||
if (isEmpty) return false;
|
||||
if (length > maxLength) return false;
|
||||
if (!validSigils.contains(substring(0, 1))) {
|
||||
return false;
|
||||
}
|
||||
// event IDs do not have to have a domain
|
||||
if (substring(0, 1) == '\$') {
|
||||
return true;
|
||||
}
|
||||
// all other matrix IDs have to have a domain
|
||||
final parts = _getParts();
|
||||
// the localpart can be an empty string, e.g. for aliases
|
||||
if (parts.length != 2 || parts[1].isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String? get sigil => isValidMatrixId ? substring(0, 1) : null;
|
||||
|
||||
String? get localpart => isValidMatrixId ? _getParts().first : null;
|
||||
|
||||
String? get domain => isValidMatrixId ? _getParts().last : null;
|
||||
|
||||
bool equals(String? other) => toLowerCase() == other?.toLowerCase();
|
||||
|
||||
/// Parse a matrix identifier string into a Uri. Primary and secondary identifiers
|
||||
/// are stored in pathSegments. The query string is stored as such.
|
||||
Uri? _parseIdentifierIntoUri() {
|
||||
const matrixUriPrefix = 'matrix:';
|
||||
const matrixToPrefix = 'https://matrix.to/#/';
|
||||
if (toLowerCase().startsWith(matrixUriPrefix)) {
|
||||
final uri = Uri.tryParse(this);
|
||||
if (uri == null) return null;
|
||||
final pathSegments = uri.pathSegments;
|
||||
final identifiers = <String>[];
|
||||
for (var i = 0; i < pathSegments.length - 1; i += 2) {
|
||||
final thisSigil = {
|
||||
'u': '@',
|
||||
'roomid': '!',
|
||||
'r': '#',
|
||||
'e': '\$',
|
||||
}[pathSegments[i].toLowerCase()];
|
||||
if (thisSigil == null) {
|
||||
break;
|
||||
}
|
||||
identifiers.add(thisSigil + pathSegments[i + 1]);
|
||||
}
|
||||
return uri.replace(pathSegments: identifiers);
|
||||
} else if (toLowerCase().startsWith(matrixToPrefix)) {
|
||||
return Uri.tryParse('//' +
|
||||
substring(matrixToPrefix.length - 1)
|
||||
.replaceAllMapped(
|
||||
RegExp(r'(?<=/)[#!@+][^:]*:|(\?.*$)'),
|
||||
(m) => m[0]!.replaceAllMapped(
|
||||
RegExp(m.group(1) != null ? '' : '[/?]'),
|
||||
(m) => Uri.encodeComponent(m.group(0)!)))
|
||||
.replaceAll('#', '%23'));
|
||||
} else {
|
||||
return Uri(
|
||||
pathSegments: RegExp(r'/((?:[#!@+][^:]*:)?[^/?]*)(?:\?.*$)?')
|
||||
.allMatches('/$this')
|
||||
.map((m) => m[1]!),
|
||||
query: RegExp(r'(?:/(?:[#!@+][^:]*:)?[^/?]*)*\?(.*$)')
|
||||
.firstMatch('/$this')?[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate a matrix identifier string into a primary indentifier, a secondary identifier,
|
||||
/// a query string and already parsed `via` parameters. A matrix identifier string
|
||||
/// can be an mxid, a matrix.to-url or a matrix-uri.
|
||||
MatrixIdentifierStringExtensionResults? parseIdentifierIntoParts() {
|
||||
final uri = _parseIdentifierIntoUri();
|
||||
if (uri == null) return null;
|
||||
final primary = uri.pathSegments.isNotEmpty ? uri.pathSegments[0] : null;
|
||||
if (primary == null || !primary.isValidMatrixId) return null;
|
||||
final secondary = uri.pathSegments.length > 1 ? uri.pathSegments[1] : null;
|
||||
if (secondary != null && !secondary.isValidMatrixId) return null;
|
||||
|
||||
return MatrixIdentifierStringExtensionResults(
|
||||
primaryIdentifier: primary,
|
||||
secondaryIdentifier: secondary,
|
||||
queryString: uri.query.isNotEmpty ? uri.query : null,
|
||||
via: (uri.queryParametersAll['via'] ?? []).toSet(),
|
||||
action: uri.queryParameters['action'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MatrixIdentifierStringExtensionResults {
|
||||
final String primaryIdentifier;
|
||||
final String? secondaryIdentifier;
|
||||
final String? queryString;
|
||||
final Set<String> via;
|
||||
final String? action;
|
||||
|
||||
MatrixIdentifierStringExtensionResults(
|
||||
{required this.primaryIdentifier,
|
||||
this.secondaryIdentifier,
|
||||
this.queryString,
|
||||
this.via = const {},
|
||||
this.action});
|
||||
}
|
||||
179
lib/src/utils/matrix_localizations.dart
Normal file
179
lib/src/utils/matrix_localizations.dart
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 '../room.dart';
|
||||
|
||||
abstract class MatrixLocalizations {
|
||||
const MatrixLocalizations();
|
||||
String get emptyChat;
|
||||
|
||||
String get invitedUsersOnly;
|
||||
|
||||
String get fromTheInvitation;
|
||||
|
||||
String get fromJoining;
|
||||
|
||||
String get visibleForAllParticipants;
|
||||
|
||||
String get visibleForEveryone;
|
||||
|
||||
String get guestsCanJoin;
|
||||
|
||||
String get guestsAreForbidden;
|
||||
|
||||
String get anyoneCanJoin;
|
||||
|
||||
String get needPantalaimonWarning;
|
||||
|
||||
String get channelCorruptedDecryptError;
|
||||
|
||||
String get encryptionNotEnabled;
|
||||
|
||||
String get unknownEncryptionAlgorithm;
|
||||
|
||||
String get noPermission;
|
||||
|
||||
String get you;
|
||||
|
||||
String get roomHasBeenUpgraded;
|
||||
|
||||
String groupWith(String displayname);
|
||||
|
||||
String removedBy(String calcDisplayname);
|
||||
|
||||
String sentASticker(String senderName);
|
||||
|
||||
String redactedAnEvent(String senderName);
|
||||
|
||||
String changedTheRoomAliases(String senderName);
|
||||
|
||||
String changedTheRoomInvitationLink(String senderName);
|
||||
|
||||
String createdTheChat(String senderName);
|
||||
|
||||
String changedTheJoinRules(String senderName);
|
||||
|
||||
String changedTheJoinRulesTo(String senderName, String localizedString);
|
||||
|
||||
String acceptedTheInvitation(String targetName);
|
||||
|
||||
String rejectedTheInvitation(String targetName);
|
||||
|
||||
String hasWithdrawnTheInvitationFor(String senderName, String targetName);
|
||||
|
||||
String joinedTheChat(String targetName);
|
||||
|
||||
String kickedAndBanned(String senderName, String targetName);
|
||||
|
||||
String kicked(String senderName, String targetName);
|
||||
|
||||
String userLeftTheChat(String targetName);
|
||||
|
||||
String bannedUser(String senderName, String targetName);
|
||||
|
||||
String unbannedUser(String senderName, String targetName);
|
||||
|
||||
String invitedUser(String senderName, String targetName);
|
||||
|
||||
String changedTheProfileAvatar(String targetName);
|
||||
|
||||
String changedTheDisplaynameTo(String targetName, String newDisplayname);
|
||||
|
||||
String changedTheChatPermissions(String senderName);
|
||||
|
||||
String changedTheChatNameTo(String senderName, String content);
|
||||
|
||||
String changedTheChatDescriptionTo(String senderName, String content);
|
||||
|
||||
String changedTheChatAvatar(String senderName);
|
||||
|
||||
String changedTheGuestAccessRules(String senderName);
|
||||
|
||||
String changedTheGuestAccessRulesTo(
|
||||
String senderName, String localizedString);
|
||||
|
||||
String changedTheHistoryVisibility(String senderName);
|
||||
|
||||
String changedTheHistoryVisibilityTo(
|
||||
String senderName, String localizedString);
|
||||
|
||||
String activatedEndToEndEncryption(String senderName);
|
||||
|
||||
String sentAPicture(String senderName);
|
||||
|
||||
String sentAFile(String senderName);
|
||||
|
||||
String sentAnAudio(String senderName);
|
||||
|
||||
String sentAVideo(String senderName);
|
||||
|
||||
String sentReaction(String senderName, String reactionKey);
|
||||
|
||||
String sharedTheLocation(String senderName);
|
||||
|
||||
String couldNotDecryptMessage(String errorText);
|
||||
|
||||
String unknownEvent(String typeKey);
|
||||
|
||||
String startedACall(String senderName);
|
||||
|
||||
String endedTheCall(String senderName);
|
||||
|
||||
String answeredTheCall(String senderName);
|
||||
|
||||
String sentCallInformations(String senderName);
|
||||
}
|
||||
|
||||
extension HistoryVisibilityDisplayString on HistoryVisibility {
|
||||
String getLocalizedString(MatrixLocalizations i18n) {
|
||||
switch (this) {
|
||||
case HistoryVisibility.invited:
|
||||
return i18n.fromTheInvitation;
|
||||
case HistoryVisibility.joined:
|
||||
return i18n.fromJoining;
|
||||
case HistoryVisibility.shared:
|
||||
return i18n.visibleForAllParticipants;
|
||||
case HistoryVisibility.worldReadable:
|
||||
return i18n.visibleForEveryone;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GuestAccessDisplayString on GuestAccess {
|
||||
String getLocalizedString(MatrixLocalizations i18n) {
|
||||
switch (this) {
|
||||
case GuestAccess.canJoin:
|
||||
return i18n.guestsCanJoin;
|
||||
case GuestAccess.forbidden:
|
||||
return i18n.guestsAreForbidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension JoinRulesDisplayString on JoinRules {
|
||||
String getLocalizedString(MatrixLocalizations i18n) {
|
||||
switch (this) {
|
||||
case JoinRules.public:
|
||||
return i18n.anyoneCanJoin;
|
||||
case JoinRules.invite:
|
||||
return i18n.invitedUsersOnly;
|
||||
default:
|
||||
return toString().replaceAll('JoinRules.', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
68
lib/src/utils/multilock.dart
Normal file
68
lib/src/utils/multilock.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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:async';
|
||||
|
||||
/// Lock management class. It allows to lock and unlock multiple keys at once. The keys have
|
||||
/// the type [T]
|
||||
class MultiLock<T> {
|
||||
final Map<T, Completer<void>> _completers = {};
|
||||
|
||||
/// Set a number of [keys] locks, awaiting them to be released previously.
|
||||
Future<void> lock(Iterable<T> keys) async {
|
||||
// An iterable might have duplicate entries. A set is guaranteed not to, and we need
|
||||
// unique entries, as else a lot of things might go bad.
|
||||
final uniqueKeys = keys.toSet();
|
||||
// we want to make sure that there are no existing completers for any of the locks
|
||||
// we are trying to set. So, we await all the completers until they are all gone.
|
||||
// We can't just assume they are all gone after one go, due to rare race conditions
|
||||
// which could then result in a deadlock.
|
||||
while (_completers.keys.any((k) => uniqueKeys.contains(k))) {
|
||||
// Here we try to build all the futures to wait for single completers and then await
|
||||
// them at the same time, in parallel
|
||||
final futures = <Future<void>>[];
|
||||
for (final key in uniqueKeys) {
|
||||
if (_completers[key] != null) {
|
||||
futures.add(() async {
|
||||
while (_completers[key] != null) {
|
||||
await _completers[key]!.future;
|
||||
}
|
||||
}());
|
||||
}
|
||||
}
|
||||
await Future.wait(futures);
|
||||
}
|
||||
// And finally set all the completers
|
||||
for (final key in uniqueKeys) {
|
||||
_completers[key] = Completer<void>();
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock all [keys] locks. Typically these should be the same keys as called
|
||||
/// in `.lock(keys)``
|
||||
void unlock(Iterable<T> keys) {
|
||||
final uniqueKeys = keys.toSet();
|
||||
// we just have to simply unlock all the completers
|
||||
for (final key in uniqueKeys) {
|
||||
if (_completers[key] != null) {
|
||||
final completer = _completers[key]!;
|
||||
_completers.remove(key);
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
lib/src/utils/queued_to_device_event.dart
Normal file
50
lib/src/utils/queued_to_device_event.dart
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2019, 2020 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';
|
||||
|
||||
class QueuedToDeviceEvent {
|
||||
final int id;
|
||||
final String type;
|
||||
final String txnId;
|
||||
final Map<String, dynamic> content;
|
||||
|
||||
QueuedToDeviceEvent({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.txnId,
|
||||
required this.content,
|
||||
});
|
||||
|
||||
factory QueuedToDeviceEvent.fromJson(Map<String, dynamic> json) =>
|
||||
QueuedToDeviceEvent(
|
||||
id: json['id'],
|
||||
type: json['type'],
|
||||
txnId: json['txn_id'],
|
||||
// Temporary fix to stay compatible to Moor AND a key value store
|
||||
content: json['content'] is String
|
||||
? jsonDecode(json['content'])
|
||||
: json['content'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'type': type,
|
||||
'txn_id': txnId,
|
||||
'content': content,
|
||||
};
|
||||
}
|
||||
33
lib/src/utils/receipt.dart
Normal file
33
lib/src/utils/receipt.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 '../user.dart';
|
||||
|
||||
/// Represents a receipt.
|
||||
/// This [user] has read an event at the given [time].
|
||||
class Receipt {
|
||||
final User user;
|
||||
final DateTime time;
|
||||
|
||||
const Receipt(this.user, this.time);
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) => (other is Receipt &&
|
||||
other.user == user &&
|
||||
other.time.microsecondsSinceEpoch == time.microsecondsSinceEpoch);
|
||||
}
|
||||
43
lib/src/utils/run_benchmarked.dart
Normal file
43
lib/src/utils/run_benchmarked.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 2019, 2020 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 'package:matrix/matrix.dart';
|
||||
|
||||
/// Calculates some benchmarks for this function. Give it a [name] and a [func]
|
||||
/// to call and it will calculate the needed milliseconds. Give it an optional
|
||||
/// [itemCount] to let it also calculate the needed milliseconds per item.
|
||||
Future<T> runBenchmarked<T>(
|
||||
String name,
|
||||
Future<T> Function() func, [
|
||||
int? itemCount,
|
||||
]) async {
|
||||
if (Logs().level.index < Level.debug.index) {
|
||||
return func();
|
||||
}
|
||||
final start = DateTime.now();
|
||||
final result = await func();
|
||||
final milliseconds =
|
||||
DateTime.now().millisecondsSinceEpoch - start.millisecondsSinceEpoch;
|
||||
var message = 'Benchmark: $name -> $milliseconds ms';
|
||||
if (itemCount != null) {
|
||||
message +=
|
||||
' ($itemCount items, ${itemCount > 0 ? milliseconds / itemCount : milliseconds} ms/item)';
|
||||
}
|
||||
Logs().d(message);
|
||||
return result;
|
||||
}
|
||||
32
lib/src/utils/run_in_root.dart
Normal file
32
lib/src/utils/run_in_root.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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:async';
|
||||
|
||||
import '../../matrix.dart';
|
||||
|
||||
Future<T?> runInRoot<T>(FutureOr<T> Function() fn) async {
|
||||
return await Zone.root.run(() async {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e, s) {
|
||||
Logs().e('Error thrown in root zone', e, s);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
47
lib/src/utils/space_child.dart
Normal file
47
lib/src/utils/space_child.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 'package:matrix_api_lite/matrix_api_lite.dart';
|
||||
|
||||
import '../event.dart';
|
||||
|
||||
class SpaceChild {
|
||||
final String? roomId;
|
||||
final List<String>? via;
|
||||
final String order;
|
||||
final bool? suggested;
|
||||
|
||||
SpaceChild.fromState(Event state)
|
||||
: assert(state.type == EventTypes.spaceChild),
|
||||
roomId = state.stateKey,
|
||||
via = state.content.tryGetList<String>('via'),
|
||||
order = state.content.tryGet<String>('order') ?? '',
|
||||
suggested = state.content.tryGet<bool>('suggested');
|
||||
}
|
||||
|
||||
class SpaceParent {
|
||||
final String? roomId;
|
||||
final List<String>? via;
|
||||
final bool? canonical;
|
||||
|
||||
SpaceParent.fromState(Event state)
|
||||
: assert(state.type == EventTypes.spaceParent),
|
||||
roomId = state.stateKey,
|
||||
via = state.content.tryGetList<String>('via'),
|
||||
canonical = state.content.tryGet<bool>('canonical');
|
||||
}
|
||||
44
lib/src/utils/sync_update_extension.dart
Normal file
44
lib/src/utils/sync_update_extension.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 '../../matrix.dart';
|
||||
|
||||
/// This extension adds easy-to-use filters for the sync update, meant to be used on the `client.onSync` stream, e.g.
|
||||
/// `client.onSync.stream.where((s) => s.hasRoomUpdate)`. Multiple filters can easily be
|
||||
/// combind with boolean logic: `client.onSync.stream.where((s) => s.hasRoomUpdate || s.hasPresenceUpdate)`
|
||||
extension SyncUpdateFilters on SyncUpdate {
|
||||
/// Returns true if this sync updat has a room update
|
||||
/// That means there is account data, if there is a room in one of the `join`, `leave` or `invite` blocks of the sync or if there is a to_device event.
|
||||
bool get hasRoomUpdate {
|
||||
// if we have an account data change we need to re-render, as `m.direct` might have changed
|
||||
if (accountData?.isNotEmpty ?? false) {
|
||||
return true;
|
||||
}
|
||||
// check for a to_device event
|
||||
if (toDevice?.isNotEmpty ?? false) {
|
||||
return true;
|
||||
}
|
||||
// return if there are rooms to update
|
||||
return (rooms?.join?.isNotEmpty ?? false) ||
|
||||
(rooms?.invite?.isNotEmpty ?? false) ||
|
||||
(rooms?.leave?.isNotEmpty ?? false);
|
||||
}
|
||||
|
||||
/// Returns if this sync update has presence updates
|
||||
bool get hasPresenceUpdate => presence?.isNotEmpty ?? false;
|
||||
}
|
||||
41
lib/src/utils/sync_update_item_count.dart
Normal file
41
lib/src/utils/sync_update_item_count.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:matrix/matrix.dart';
|
||||
|
||||
extension SyncUpdateItemCount on SyncUpdate {
|
||||
int get itemCount {
|
||||
var count = 0;
|
||||
count += accountData?.length ?? 0;
|
||||
count += deviceLists?.changed?.length ?? 0;
|
||||
count += deviceLists?.left?.length ?? 0;
|
||||
count += toDevice?.length ?? 0;
|
||||
count += presence?.length ?? 0;
|
||||
count += _joinRoomsItemCount;
|
||||
count += _inviteRoomsItemCount;
|
||||
count += _leaveRoomsItemCount;
|
||||
return count;
|
||||
}
|
||||
|
||||
int get _joinRoomsItemCount =>
|
||||
rooms?.join?.values.fold<int>(
|
||||
0,
|
||||
(prev, room) =>
|
||||
prev +
|
||||
(room.accountData?.length ?? 0) +
|
||||
(room.state?.length ?? 0) +
|
||||
(room.timeline?.events?.length ?? 0)) ??
|
||||
0;
|
||||
|
||||
int get _inviteRoomsItemCount =>
|
||||
rooms?.invite?.values.fold<int>(
|
||||
0, (prev, room) => prev + (room.inviteState?.length ?? 0)) ??
|
||||
0;
|
||||
|
||||
int get _leaveRoomsItemCount =>
|
||||
rooms?.leave?.values.fold<int>(
|
||||
0,
|
||||
(prev, room) =>
|
||||
prev +
|
||||
(room.accountData?.length ?? 0) +
|
||||
(room.state?.length ?? 0) +
|
||||
(room.timeline?.events?.length ?? 0)) ??
|
||||
0;
|
||||
}
|
||||
53
lib/src/utils/to_device_event.dart
Normal file
53
lib/src/utils/to_device_event.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 '../../matrix.dart';
|
||||
|
||||
class ToDeviceEvent extends BasicEventWithSender {
|
||||
Map<String, dynamic>? encryptedContent;
|
||||
|
||||
String get sender => senderId;
|
||||
set sender(String sender) => senderId = sender;
|
||||
|
||||
ToDeviceEvent({
|
||||
required String sender,
|
||||
required String type,
|
||||
required Map<String, dynamic> content,
|
||||
this.encryptedContent,
|
||||
}) : super(senderId: sender, type: type, content: content);
|
||||
|
||||
factory ToDeviceEvent.fromJson(Map<String, dynamic> json) {
|
||||
final event = BasicEventWithSender.fromJson(json);
|
||||
return ToDeviceEvent(
|
||||
sender: event.senderId, type: event.type, content: event.content);
|
||||
}
|
||||
}
|
||||
|
||||
class ToDeviceEventDecryptionError extends ToDeviceEvent {
|
||||
Exception exception;
|
||||
StackTrace? stackTrace;
|
||||
ToDeviceEventDecryptionError({
|
||||
required ToDeviceEvent toDeviceEvent,
|
||||
required this.exception,
|
||||
this.stackTrace,
|
||||
}) : super(
|
||||
sender: toDeviceEvent.senderId,
|
||||
content: toDeviceEvent.content,
|
||||
type: toDeviceEvent.type,
|
||||
);
|
||||
}
|
||||
120
lib/src/utils/uia_request.dart
Normal file
120
lib/src/utils/uia_request.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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 '../../matrix.dart';
|
||||
|
||||
enum UiaRequestState {
|
||||
/// The request is done
|
||||
done,
|
||||
|
||||
/// The request has failed
|
||||
fail,
|
||||
|
||||
/// The request is currently loading
|
||||
loading,
|
||||
|
||||
/// The request is waiting for user interaction
|
||||
waitForUser,
|
||||
}
|
||||
|
||||
/// Wrapper to handle User interactive authentication requests
|
||||
class UiaRequest<T> {
|
||||
void Function(UiaRequestState state)? onUpdate;
|
||||
final Future<T> Function(AuthenticationData? auth) request;
|
||||
String? session;
|
||||
UiaRequestState _state = UiaRequestState.loading;
|
||||
T? result;
|
||||
Exception? error;
|
||||
Set<String> nextStages = <String>{};
|
||||
Map<String, dynamic> params = <String, dynamic>{};
|
||||
|
||||
UiaRequestState get state => _state;
|
||||
|
||||
set state(UiaRequestState newState) {
|
||||
if (_state == newState) return;
|
||||
_state = newState;
|
||||
onUpdate?.call(newState);
|
||||
}
|
||||
|
||||
UiaRequest({this.onUpdate, required this.request}) {
|
||||
_run();
|
||||
}
|
||||
|
||||
Future<T?> _run([AuthenticationData? auth]) async {
|
||||
state = UiaRequestState.loading;
|
||||
try {
|
||||
final res = await request(auth);
|
||||
state = UiaRequestState.done;
|
||||
result = res;
|
||||
return res;
|
||||
} on MatrixException catch (err) {
|
||||
if (err.session == null) {
|
||||
error = err;
|
||||
state = UiaRequestState.fail;
|
||||
return null;
|
||||
}
|
||||
session ??= err.session;
|
||||
final completed = err.completedAuthenticationFlows;
|
||||
final flows = err.authenticationFlows ?? <AuthenticationFlow>[];
|
||||
params = err.authenticationParams ?? <String, dynamic>{};
|
||||
nextStages = getNextStages(flows, completed);
|
||||
if (nextStages.isEmpty) {
|
||||
error = err;
|
||||
state = UiaRequestState.fail;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
error = err is Exception ? err : Exception(err);
|
||||
state = UiaRequestState.fail;
|
||||
return null;
|
||||
} finally {
|
||||
if (state == UiaRequestState.loading) {
|
||||
state = UiaRequestState.waitForUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<T?> completeStage(AuthenticationData auth) => _run(auth);
|
||||
|
||||
/// Cancel this uia request for example if the app can not handle this stage.
|
||||
void cancel([Exception? err]) {
|
||||
error = err ?? Exception('Request has been canceled');
|
||||
state = UiaRequestState.fail;
|
||||
}
|
||||
|
||||
Set<String> getNextStages(
|
||||
List<AuthenticationFlow> flows, List<String> completed) {
|
||||
final nextStages = <String>{};
|
||||
for (final flow in flows) {
|
||||
final stages = flow.stages;
|
||||
final nextStage = stages[completed.length];
|
||||
var stagesValid = true;
|
||||
for (var i = 0; i < completed.length; i++) {
|
||||
if (stages[i] != completed[i]) {
|
||||
stagesValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (stagesValid) {
|
||||
nextStages.add(nextStage);
|
||||
}
|
||||
}
|
||||
return nextStages;
|
||||
}
|
||||
}
|
||||
63
lib/src/utils/uri_extension.dart
Normal file
63
lib/src/utils/uri_extension.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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:core';
|
||||
|
||||
import '../client.dart';
|
||||
|
||||
extension MxcUriExtension on Uri {
|
||||
/// Returns a download Link to this content.
|
||||
Uri getDownloadLink(Client matrix) => isScheme('mxc')
|
||||
? matrix.homeserver != null
|
||||
? matrix.homeserver?.resolve(
|
||||
'_matrix/media/r0/download/$host${hasPort ? ':$port' : ''}$path') ??
|
||||
Uri()
|
||||
: Uri()
|
||||
: this;
|
||||
|
||||
/// Returns a scaled thumbnail link to this content with the given `width` and
|
||||
/// `height`. `method` can be `ThumbnailMethod.crop` or
|
||||
/// `ThumbnailMethod.scale` and defaults to `ThumbnailMethod.scale`.
|
||||
/// If `animated` (default false) is set to true, an animated thumbnail is requested
|
||||
/// as per MSC2705. Thumbnails only animate if the media repository supports that.
|
||||
Uri getThumbnail(Client matrix,
|
||||
{num? width,
|
||||
num? height,
|
||||
ThumbnailMethod? method = ThumbnailMethod.crop,
|
||||
bool? animated = false}) {
|
||||
if (!isScheme('mxc')) return this;
|
||||
final homeserver = matrix.homeserver;
|
||||
if (homeserver == null) {
|
||||
return Uri();
|
||||
}
|
||||
return Uri(
|
||||
scheme: homeserver.scheme,
|
||||
host: homeserver.host,
|
||||
path: '/_matrix/media/r0/thumbnail/$host${hasPort ? ':$port' : ''}$path',
|
||||
port: homeserver.port,
|
||||
queryParameters: {
|
||||
if (width != null) 'width': width.round().toString(),
|
||||
if (height != null) 'height': height.round().toString(),
|
||||
if (method != null) 'method': method.toString().split('.').last,
|
||||
if (animated != null) 'animated': animated.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ThumbnailMethod { crop, scale }
|
||||
1508
lib/src/voip.dart
Normal file
1508
lib/src/voip.dart
Normal file
File diff suppressed because it is too large
Load diff
160
lib/src/voip_content.dart
Normal file
160
lib/src/voip_content.dart
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Famedly Matrix SDK
|
||||
* Copyright (C) 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/>.
|
||||
*/
|
||||
|
||||
class CallReplacesTarget {
|
||||
String? id;
|
||||
String? display_name;
|
||||
String? avatar_url;
|
||||
|
||||
CallReplacesTarget({this.id, this.display_name, this.avatar_url});
|
||||
factory CallReplacesTarget.fromJson(Map<String, dynamic> json) =>
|
||||
CallReplacesTarget(
|
||||
id: json['id'].toString(),
|
||||
display_name: json['display_name'].toString(),
|
||||
avatar_url: json['avatar_url'].toString(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (id != null) 'id': id,
|
||||
if (display_name != null) 'display_name': display_name,
|
||||
if (avatar_url != null) 'avatar_url': avatar_url,
|
||||
};
|
||||
}
|
||||
|
||||
/// MSC2747: VoIP call transfers
|
||||
/// https://github.com/matrix-org/matrix-doc/pull/2747
|
||||
class CallReplaces {
|
||||
String? replacement_id;
|
||||
CallReplacesTarget? target_user;
|
||||
String? create_call;
|
||||
String? await_call;
|
||||
String? target_room;
|
||||
|
||||
CallReplaces({
|
||||
this.replacement_id,
|
||||
this.target_user,
|
||||
this.create_call,
|
||||
this.await_call,
|
||||
this.target_room,
|
||||
});
|
||||
factory CallReplaces.fromJson(Map<String, dynamic> json) => CallReplaces(
|
||||
replacement_id: json['replacement_id']?.toString(),
|
||||
create_call: json['create_call']?.toString(),
|
||||
await_call: json['await_call']?.toString(),
|
||||
target_room: json['target_room']?.toString(),
|
||||
target_user: CallReplacesTarget.fromJson(json['target_user']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (replacement_id != null) 'replacement_id': replacement_id,
|
||||
if (target_user != null) 'target_user': target_user!.toJson(),
|
||||
if (create_call != null) 'create_call': create_call,
|
||||
if (await_call != null) 'await_call': await_call,
|
||||
if (target_room != null) 'target_room': target_room,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged
|
||||
const String sdpStreamMetadataKey = 'org.matrix.msc3077.sdp_stream_metadata';
|
||||
|
||||
/// https://github.com/matrix-org/matrix-doc/blob/dbkr/msc2747/proposals/2747-voip-call-transfer.md#capability-advertisment
|
||||
/// https://github.com/matrix-org/matrix-doc/blob/dbkr/msc2746/proposals/2746-reliable-voip.md#add-dtmf
|
||||
class CallCapabilities {
|
||||
bool transferee;
|
||||
bool dtmf;
|
||||
CallCapabilities({this.transferee = false, this.dtmf = false});
|
||||
factory CallCapabilities.fromJson(Map<String, dynamic> json) =>
|
||||
CallCapabilities(
|
||||
dtmf: json['m.call.dtmf'] as bool? ?? false,
|
||||
transferee: json['m.call.transferee'] as bool? ?? false,
|
||||
);
|
||||
Map<String, dynamic> toJson() => {
|
||||
'm.call.transferee': transferee,
|
||||
'm.call.dtmf': dtmf,
|
||||
};
|
||||
}
|
||||
|
||||
/// MSC3077: Support for multi-stream VoIP
|
||||
/// https://github.com/matrix-org/matrix-doc/pull/3077
|
||||
///
|
||||
/// MSC3291: Muting in VoIP calls
|
||||
/// https://github.com/SimonBrandner/matrix-doc/blob/msc/muting/proposals/3291-muting.md
|
||||
///
|
||||
/// This MSC proposes adding an sdp_stream_metadata field
|
||||
/// to the events containing a session description i.e.:
|
||||
/// m.call.invite, m.call.answer, m.call.negotiate
|
||||
///
|
||||
class SDPStreamPurpose {
|
||||
// SDPStreamMetadataPurpose
|
||||
String purpose;
|
||||
bool audio_muted;
|
||||
bool video_muted;
|
||||
|
||||
SDPStreamPurpose(
|
||||
{required this.purpose,
|
||||
this.audio_muted = false,
|
||||
this.video_muted = false});
|
||||
factory SDPStreamPurpose.fromJson(Map<String, dynamic> json) =>
|
||||
SDPStreamPurpose(
|
||||
audio_muted: json['audio_muted'] as bool? ?? false,
|
||||
video_muted: json['video_muted'] as bool? ?? false,
|
||||
purpose: json['purpose'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'purpose': purpose,
|
||||
'audio_muted': audio_muted,
|
||||
'video_muted': video_muted,
|
||||
};
|
||||
}
|
||||
|
||||
class SDPStreamMetadataPurpose {
|
||||
static String Usermedia = 'm.usermedia';
|
||||
static String Screenshare = 'm.screenshare';
|
||||
}
|
||||
|
||||
class SDPStreamMetadata {
|
||||
Map<String, SDPStreamPurpose> sdpStreamMetadatas;
|
||||
SDPStreamMetadata(this.sdpStreamMetadatas);
|
||||
|
||||
factory SDPStreamMetadata.fromJson(Map<String, dynamic> json) =>
|
||||
SDPStreamMetadata(json.map(
|
||||
(key, value) => MapEntry(key, SDPStreamPurpose.fromJson(value))));
|
||||
Map<String, dynamic> toJson() =>
|
||||
sdpStreamMetadatas.map((key, value) => MapEntry(key, value.toJson()));
|
||||
}
|
||||
|
||||
/// MSC3086: Asserted identity on VoIP calls
|
||||
/// https://github.com/matrix-org/matrix-doc/pull/3086
|
||||
class AssertedIdentity {
|
||||
String? id;
|
||||
String? displayName;
|
||||
String? avatarUrl;
|
||||
AssertedIdentity({this.id, this.displayName, this.avatarUrl});
|
||||
factory AssertedIdentity.fromJson(Map<String, dynamic> json) =>
|
||||
AssertedIdentity(
|
||||
displayName: json['display_name'] as String?,
|
||||
id: json['id'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
);
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (displayName != null) 'display_name': displayName,
|
||||
if (id != null) 'id': id,
|
||||
if (avatarUrl != null) 'avatar_url': avatarUrl,
|
||||
};
|
||||
}
|
||||
69
lib/widget.dart
Normal file
69
lib/widget.dart
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import 'package:matrix/src/room.dart';
|
||||
|
||||
class MatrixWidget {
|
||||
final Room room;
|
||||
final String? creatorUserId;
|
||||
final Map<String, dynamic>? data;
|
||||
final String? id;
|
||||
final String? name;
|
||||
final String type;
|
||||
|
||||
/// use [buildWidgetUrl] instead
|
||||
final String url;
|
||||
final bool waitForIframeLoad;
|
||||
|
||||
MatrixWidget({
|
||||
required this.room,
|
||||
this.creatorUserId,
|
||||
this.data = const {},
|
||||
this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.url,
|
||||
this.waitForIframeLoad = false,
|
||||
});
|
||||
|
||||
factory MatrixWidget.fromJson(Map<String, dynamic> json, Room room) =>
|
||||
MatrixWidget(
|
||||
room: room,
|
||||
creatorUserId:
|
||||
json.containsKey('creatorUserId') ? json['creatorUserId'] : null,
|
||||
data: json.containsKey('data') ? json['data'] : {},
|
||||
id: json.containsKey('id') ? json['id'] : null,
|
||||
name: json['name'],
|
||||
type: json['type'],
|
||||
url: json['url'],
|
||||
waitForIframeLoad: json.containsKey('waitForIframeLoad')
|
||||
? json['waitForIframeLoad']
|
||||
: false,
|
||||
);
|
||||
|
||||
Future<Uri> buildWidgetUrl() async {
|
||||
// See https://github.com/matrix-org/matrix-doc/issues/1236 for a
|
||||
// description, specifically the section
|
||||
// `What does the other stuff in content mean?`
|
||||
final userProfile = await room.client.ownProfile;
|
||||
var parsedUri = url;
|
||||
|
||||
// a key-value map with the strings to be replaced
|
||||
final replaceMap = {
|
||||
r'$matrix_user_id': userProfile.userId,
|
||||
r'$matrix_room_id': room.id,
|
||||
r'$matrix_display_name': userProfile.displayName ?? '',
|
||||
r'$matrix_avatar_url': userProfile.avatarUrl?.toString() ?? '',
|
||||
// removing potentially dangerous keys containing anything but
|
||||
// `[a-zA-Z0-9_-]` as well as non string values
|
||||
if (data != null)
|
||||
...Map.from(data!)
|
||||
..removeWhere((key, value) =>
|
||||
!RegExp(r'^[\w-]+$').hasMatch(key) || !value is String)
|
||||
..map((key, value) => MapEntry('\$key', value)),
|
||||
};
|
||||
|
||||
replaceMap.forEach((key, value) {
|
||||
parsedUri = parsedUri.replaceAll(key, Uri.encodeComponent(value));
|
||||
});
|
||||
|
||||
return Uri.parse(parsedUri);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue