fix: Cleanup nullsafe encryption a bit
This commit is contained in:
parent
da80658c09
commit
1c838e3be8
|
|
@ -149,8 +149,7 @@ class Encryption {
|
||||||
runInRoot(() => keyVerificationManager.handleEventUpdate(update));
|
runInRoot(() => keyVerificationManager.handleEventUpdate(update));
|
||||||
}
|
}
|
||||||
if (update.content['sender'] == client.userID &&
|
if (update.content['sender'] == client.userID &&
|
||||||
(!update.content.containsKey('unsigned') ||
|
update.content['unsigned']?['transaction_id'] == null) {
|
||||||
!update.content['unsigned'].containsKey('transaction_id'))) {
|
|
||||||
// maybe we need to re-try SSSS secrets
|
// maybe we need to re-try SSSS secrets
|
||||||
// ignore: unawaited_futures
|
// ignore: unawaited_futures
|
||||||
runInRoot(() => ssss.periodicallyRequestMissingCache());
|
runInRoot(() => ssss.periodicallyRequestMissingCache());
|
||||||
|
|
@ -375,15 +374,13 @@ class Encryption {
|
||||||
|
|
||||||
Future<void> autovalidateMasterOwnKey() async {
|
Future<void> autovalidateMasterOwnKey() async {
|
||||||
// check if we can set our own master key as verified, if it isn't yet
|
// check if we can set our own master key as verified, if it isn't yet
|
||||||
|
final masterKey = client.userDeviceKeys[client.userID]?.masterKey;
|
||||||
if (client.database != null &&
|
if (client.database != null &&
|
||||||
client.userDeviceKeys.containsKey(client.userID)) {
|
masterKey != null &&
|
||||||
final masterKey = client.userDeviceKeys[client.userID]!.masterKey;
|
!masterKey.directVerified &&
|
||||||
if (masterKey != null &&
|
masterKey
|
||||||
!masterKey.directVerified &&
|
.hasValidSignatureChain(onlyValidateUserIds: {client.userID})) {
|
||||||
masterKey
|
await masterKey.setVerified(true);
|
||||||
.hasValidSignatureChain(onlyValidateUserIds: {client.userID})) {
|
|
||||||
await masterKey.setVerified(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,10 +146,10 @@ class KeyManager {
|
||||||
newSession.dispose();
|
newSession.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_inboundGroupSessions.containsKey(roomId)) {
|
|
||||||
_inboundGroupSessions[roomId] = <String, SessionKey>{};
|
final roomInboundGroupSessions =
|
||||||
}
|
_inboundGroupSessions[roomId] ??= <String, SessionKey>{};
|
||||||
_inboundGroupSessions[roomId]![sessionId] = newSession;
|
roomInboundGroupSessions[sessionId] = newSession;
|
||||||
if (!client.isLogged() || client.encryption == null) {
|
if (!client.isLogged() || client.encryption == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -189,9 +189,8 @@ class KeyManager {
|
||||||
SessionKey? getInboundGroupSession(
|
SessionKey? getInboundGroupSession(
|
||||||
String roomId, String sessionId, String senderKey,
|
String roomId, String sessionId, String senderKey,
|
||||||
{bool otherRooms = true}) {
|
{bool otherRooms = true}) {
|
||||||
if (_inboundGroupSessions.containsKey(roomId) &&
|
final sess = _inboundGroupSessions[roomId]?[sessionId];
|
||||||
_inboundGroupSessions[roomId]!.containsKey(sessionId)) {
|
if (sess != null) {
|
||||||
final sess = _inboundGroupSessions[roomId]![sessionId]!;
|
|
||||||
if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) {
|
if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -202,8 +201,8 @@ class KeyManager {
|
||||||
}
|
}
|
||||||
// search if this session id is *somehow* found in another room
|
// search if this session id is *somehow* found in another room
|
||||||
for (final val in _inboundGroupSessions.values) {
|
for (final val in _inboundGroupSessions.values) {
|
||||||
if (val.containsKey(sessionId)) {
|
final sess = val[sessionId];
|
||||||
final sess = val[sessionId]!;
|
if (sess != null) {
|
||||||
if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) {
|
if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -231,9 +230,8 @@ class KeyManager {
|
||||||
/// Loads an inbound group session
|
/// Loads an inbound group session
|
||||||
Future<SessionKey?> loadInboundGroupSession(
|
Future<SessionKey?> loadInboundGroupSession(
|
||||||
String roomId, String sessionId, String senderKey) async {
|
String roomId, String sessionId, String senderKey) async {
|
||||||
if (_inboundGroupSessions.containsKey(roomId) &&
|
final sess = _inboundGroupSessions[roomId]?[sessionId];
|
||||||
_inboundGroupSessions[roomId]!.containsKey(sessionId)) {
|
if (sess != null) {
|
||||||
final sess = _inboundGroupSessions[roomId]![sessionId]!;
|
|
||||||
if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) {
|
if (sess.senderKey != senderKey && sess.senderKey.isNotEmpty) {
|
||||||
return null; // sender keys do not match....better not do anything
|
return null; // sender keys do not match....better not do anything
|
||||||
}
|
}
|
||||||
|
|
@ -244,16 +242,15 @@ class KeyManager {
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final sess = SessionKey.fromDb(session, client.userID);
|
final dbSess = SessionKey.fromDb(session, client.userID);
|
||||||
if (!_inboundGroupSessions.containsKey(roomId)) {
|
final roomInboundGroupSessions =
|
||||||
_inboundGroupSessions[roomId] = <String, SessionKey>{};
|
_inboundGroupSessions[roomId] ??= <String, SessionKey>{};
|
||||||
}
|
if (!dbSess.isValid ||
|
||||||
if (!sess.isValid ||
|
dbSess.senderKey.isEmpty ||
|
||||||
sess.senderKey.isEmpty ||
|
dbSess.senderKey != senderKey) {
|
||||||
sess.senderKey != senderKey) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
_inboundGroupSessions[roomId]![sessionId] = sess;
|
roomInboundGroupSessions[sessionId] = dbSess;
|
||||||
return sess;
|
return sess;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,14 +258,13 @@ class KeyManager {
|
||||||
List<DeviceKeys> deviceKeys) {
|
List<DeviceKeys> deviceKeys) {
|
||||||
final deviceKeyIds = <String, Map<String, bool>>{};
|
final deviceKeyIds = <String, Map<String, bool>>{};
|
||||||
for (final device in deviceKeys) {
|
for (final device in deviceKeys) {
|
||||||
if (device.deviceId == null) {
|
final deviceId = device.deviceId;
|
||||||
|
if (deviceId == null) {
|
||||||
Logs().w('[KeyManager] ignoring device without deviceid');
|
Logs().w('[KeyManager] ignoring device without deviceid');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!deviceKeyIds.containsKey(device.userId)) {
|
final userDeviceKeyIds = deviceKeyIds[device.userId] ??= <String, bool>{};
|
||||||
deviceKeyIds[device.userId] = <String, bool>{};
|
userDeviceKeyIds[deviceId] = !device.encryptToDevice;
|
||||||
}
|
|
||||||
deviceKeyIds[device.userId]![device.deviceId!] = !device.encryptToDevice;
|
|
||||||
}
|
}
|
||||||
return deviceKeyIds;
|
return deviceKeyIds;
|
||||||
}
|
}
|
||||||
|
|
@ -440,8 +436,9 @@ class KeyManager {
|
||||||
|
|
||||||
/// Creates an outbound group session for a given room id
|
/// Creates an outbound group session for a given room id
|
||||||
Future<OutboundGroupSession> createOutboundGroupSession(String roomId) async {
|
Future<OutboundGroupSession> createOutboundGroupSession(String roomId) async {
|
||||||
if (_pendingNewOutboundGroupSessions.containsKey(roomId)) {
|
final sess = _pendingNewOutboundGroupSessions[roomId];
|
||||||
return _pendingNewOutboundGroupSessions[roomId]!;
|
if (sess != null) {
|
||||||
|
return sess;
|
||||||
}
|
}
|
||||||
_pendingNewOutboundGroupSessions[roomId] =
|
_pendingNewOutboundGroupSessions[roomId] =
|
||||||
_createOutboundGroupSession(roomId);
|
_createOutboundGroupSession(roomId);
|
||||||
|
|
@ -791,14 +788,12 @@ class KeyManager {
|
||||||
Logs().i('[KeyManager] No body, doing nothing');
|
Logs().i('[KeyManager] No body, doing nothing');
|
||||||
return; // no body
|
return; // no body
|
||||||
}
|
}
|
||||||
if (!client.userDeviceKeys.containsKey(event.sender) ||
|
final device = client.userDeviceKeys[event.sender]
|
||||||
!client.userDeviceKeys[event.sender]!.deviceKeys
|
?.deviceKeys[event.content['requesting_device_id']];
|
||||||
.containsKey(event.content['requesting_device_id'])) {
|
if (device == null) {
|
||||||
Logs().i('[KeyManager] Device not found, doing nothing');
|
Logs().i('[KeyManager] Device not found, doing nothing');
|
||||||
return; // device not found
|
return; // device not found
|
||||||
}
|
}
|
||||||
final device = client.userDeviceKeys[event.sender]!
|
|
||||||
.deviceKeys[event.content['requesting_device_id']]!;
|
|
||||||
if (device.userId == client.userID &&
|
if (device.userId == client.userID &&
|
||||||
device.deviceId == client.deviceID) {
|
device.deviceId == client.deviceID) {
|
||||||
Logs().i('[KeyManager] Request is by ourself, ignoring');
|
Logs().i('[KeyManager] Request is by ourself, ignoring');
|
||||||
|
|
@ -914,10 +909,8 @@ class KeyManager {
|
||||||
};
|
};
|
||||||
final data = <String, Map<String, Map<String, dynamic>>>{};
|
final data = <String, Map<String, Map<String, dynamic>>>{};
|
||||||
for (final device in request.devices) {
|
for (final device in request.devices) {
|
||||||
if (!data.containsKey(device.userId)) {
|
final userData = data[device.userId] ??= {};
|
||||||
data[device.userId] = {};
|
userData[device.deviceId!] = sendToDeviceMessage;
|
||||||
}
|
|
||||||
data[device.userId]![device.deviceId!] = sendToDeviceMessage;
|
|
||||||
}
|
}
|
||||||
await client.sendToDevice(
|
await client.sendToDevice(
|
||||||
EventTypes.RoomKeyRequest,
|
EventTypes.RoomKeyRequest,
|
||||||
|
|
@ -933,13 +926,10 @@ class KeyManager {
|
||||||
}
|
}
|
||||||
final String roomId = event.content['room_id'];
|
final String roomId = event.content['room_id'];
|
||||||
final String sessionId = event.content['session_id'];
|
final String sessionId = event.content['session_id'];
|
||||||
if (client.userDeviceKeys.containsKey(event.sender) &&
|
final sender_ed25519 = client.userDeviceKeys[event.sender]
|
||||||
client.userDeviceKeys[event.sender]!.deviceKeys
|
?.deviceKeys[event.content['requesting_device_id']]?.ed25519Key;
|
||||||
.containsKey(event.content['requesting_device_id'])) {
|
if (sender_ed25519 != null) {
|
||||||
event.content['sender_claimed_ed25519_key'] = client
|
event.content['sender_claimed_ed25519_key'] = sender_ed25519;
|
||||||
.userDeviceKeys[event.sender]!
|
|
||||||
.deviceKeys[event.content['requesting_device_id']]!
|
|
||||||
.ed25519Key;
|
|
||||||
}
|
}
|
||||||
Logs().v('[KeyManager] Keeping room key');
|
Logs().v('[KeyManager] Keeping room key');
|
||||||
setInboundGroupSession(roomId, sessionId,
|
setInboundGroupSession(roomId, sessionId,
|
||||||
|
|
@ -1044,9 +1034,8 @@ RoomKeys _generateUploadKeys(_GenerateUploadKeysArgs args) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// create the room if it doesn't exist
|
// create the room if it doesn't exist
|
||||||
if (!roomKeys.rooms.containsKey(sess.roomId)) {
|
final roomKeyBackup =
|
||||||
roomKeys.rooms[sess.roomId] = RoomKeyBackup(sessions: {});
|
roomKeys.rooms[sess.roomId] ??= RoomKeyBackup(sessions: {});
|
||||||
}
|
|
||||||
// generate the encrypted content
|
// generate the encrypted content
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
'algorithm': AlgorithmTypes.megolmV1AesSha2,
|
'algorithm': AlgorithmTypes.megolmV1AesSha2,
|
||||||
|
|
@ -1061,7 +1050,7 @@ RoomKeys _generateUploadKeys(_GenerateUploadKeysArgs args) {
|
||||||
// fetch the device, if available...
|
// fetch the device, if available...
|
||||||
//final device = args.client.getUserDeviceKeysByCurve25519Key(sess.senderKey);
|
//final device = args.client.getUserDeviceKeysByCurve25519Key(sess.senderKey);
|
||||||
// aaaand finally add the session key to our payload
|
// aaaand finally add the session key to our payload
|
||||||
roomKeys.rooms[sess.roomId]!.sessions[sess.sessionId] = KeyBackupData(
|
roomKeyBackup.sessions[sess.sessionId] = KeyBackupData(
|
||||||
firstMessageIndex: sess.inboundGroupSession!.first_known_index(),
|
firstMessageIndex: sess.inboundGroupSession!.first_known_index(),
|
||||||
forwardedCount: sess.forwardingCurve25519KeyChain.length,
|
forwardedCount: sess.forwardingCurve25519KeyChain.length,
|
||||||
isVerified: dbSession.verified, //device?.verified ?? false,
|
isVerified: dbSession.verified, //device?.verified ?? false,
|
||||||
|
|
|
||||||
|
|
@ -411,12 +411,10 @@ class OlmManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final Map<String, dynamic> plainContent = json.decode(plaintext);
|
final Map<String, dynamic> plainContent = json.decode(plaintext);
|
||||||
if (plainContent.containsKey('sender') &&
|
if (plainContent['sender'] != event.sender) {
|
||||||
plainContent['sender'] != event.sender) {
|
|
||||||
throw DecryptException(DecryptException.senderDoesntMatch);
|
throw DecryptException(DecryptException.senderDoesntMatch);
|
||||||
}
|
}
|
||||||
if (plainContent.containsKey('recipient') &&
|
if (plainContent['recipient'] != client.userID) {
|
||||||
plainContent['recipient'] != client.userID) {
|
|
||||||
throw DecryptException(DecryptException.recipientDoesntMatch);
|
throw DecryptException(DecryptException.recipientDoesntMatch);
|
||||||
}
|
}
|
||||||
if (plainContent['recipient_keys'] is Map &&
|
if (plainContent['recipient_keys'] is Map &&
|
||||||
|
|
@ -637,19 +635,16 @@ class OlmManager {
|
||||||
}
|
}
|
||||||
final deviceKeysWithoutSession = List<DeviceKeys>.from(deviceKeys);
|
final deviceKeysWithoutSession = List<DeviceKeys>.from(deviceKeys);
|
||||||
deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) =>
|
deviceKeysWithoutSession.removeWhere((DeviceKeys deviceKeys) =>
|
||||||
olmSessions.containsKey(deviceKeys.curve25519Key) &&
|
olmSessions[deviceKeys.curve25519Key]?.isNotEmpty ?? false);
|
||||||
olmSessions[deviceKeys.curve25519Key]!.isNotEmpty);
|
|
||||||
if (deviceKeysWithoutSession.isNotEmpty) {
|
if (deviceKeysWithoutSession.isNotEmpty) {
|
||||||
await startOutgoingOlmSessions(deviceKeysWithoutSession);
|
await startOutgoingOlmSessions(deviceKeysWithoutSession);
|
||||||
}
|
}
|
||||||
for (final device in deviceKeys) {
|
for (final device in deviceKeys) {
|
||||||
if (!data.containsKey(device.userId)) {
|
final userData = data[device.userId] ??= {};
|
||||||
data[device.userId] = {};
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
data[device.userId]![device.deviceId!] =
|
userData[device.deviceId!] = await encryptToDeviceMessagePayload(
|
||||||
await encryptToDeviceMessagePayload(device, type, payload,
|
device, type, payload,
|
||||||
getFromDb: false);
|
getFromDb: false);
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
Logs().w('[LibOlm] Error encrypting to-device event', e, s);
|
Logs().w('[LibOlm] Error encrypting to-device event', e, s);
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -90,10 +90,11 @@ class Bootstrap {
|
||||||
// cache the secret analyzing so that we don't drop stuff a different client sets during bootstrapping
|
// 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>>? _secretsCache;
|
||||||
Map<String, Set<String>> analyzeSecrets() {
|
Map<String, Set<String>> analyzeSecrets() {
|
||||||
if (_secretsCache != null) {
|
final secretsCache = _secretsCache;
|
||||||
|
if (secretsCache != null) {
|
||||||
// deep-copy so that we can do modifications
|
// deep-copy so that we can do modifications
|
||||||
final newSecrets = <String, Set<String>>{};
|
final newSecrets = <String, Set<String>>{};
|
||||||
for (final s in _secretsCache!.entries) {
|
for (final s in secretsCache.entries) {
|
||||||
newSecrets[s.key] = Set<String>.from(s.value);
|
newSecrets[s.key] = Set<String>.from(s.value);
|
||||||
}
|
}
|
||||||
return newSecrets;
|
return newSecrets;
|
||||||
|
|
@ -147,11 +148,7 @@ class Bootstrap {
|
||||||
final usage = <String, int>{};
|
final usage = <String, int>{};
|
||||||
for (final keys in secrets.values) {
|
for (final keys in secrets.values) {
|
||||||
for (final key in keys) {
|
for (final key in keys) {
|
||||||
if (!usage.containsKey(key)) {
|
usage.update(key, (i) => i++, ifAbsent: () => 1);
|
||||||
usage[key] = 1;
|
|
||||||
} else {
|
|
||||||
usage[key] = usage[key]! + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final entriesList = usage.entries.toList();
|
final entriesList = usage.entries.toList();
|
||||||
|
|
@ -315,14 +312,15 @@ class Bootstrap {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openExistingSsss() async {
|
Future<void> openExistingSsss() async {
|
||||||
if (state != BootstrapState.openExistingSsss || newSsssKey == null) {
|
final newSsssKey_ = newSsssKey;
|
||||||
|
if (state != BootstrapState.openExistingSsss || newSsssKey_ == null) {
|
||||||
throw BootstrapBadStateException();
|
throw BootstrapBadStateException();
|
||||||
}
|
}
|
||||||
if (!newSsssKey!.isUnlocked) {
|
if (!newSsssKey_.isUnlocked) {
|
||||||
throw BootstrapBadStateException('Key not unlocked');
|
throw BootstrapBadStateException('Key not unlocked');
|
||||||
}
|
}
|
||||||
Logs().v('Maybe cache all...');
|
Logs().v('Maybe cache all...');
|
||||||
await newSsssKey!.maybeCacheAll();
|
await newSsssKey_.maybeCacheAll();
|
||||||
checkCrossSigning();
|
checkCrossSigning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,12 +475,9 @@ class Bootstrap {
|
||||||
futures.add(
|
futures.add(
|
||||||
client.onSync.stream
|
client.onSync.stream
|
||||||
.firstWhere((syncUpdate) =>
|
.firstWhere((syncUpdate) =>
|
||||||
client.userDeviceKeys.containsKey(client.userID) &&
|
masterKey?.publicKey != null &&
|
||||||
client.userDeviceKeys[client.userID]!.masterKey != null &&
|
client.userDeviceKeys[client.userID]?.masterKey?.ed25519Key ==
|
||||||
client.userDeviceKeys[client.userID]!.masterKey!.ed25519Key !=
|
masterKey?.publicKey)
|
||||||
null &&
|
|
||||||
client.userDeviceKeys[client.userID]!.masterKey!.ed25519Key ==
|
|
||||||
masterKey!.publicKey)
|
|
||||||
.then((_) => Logs().v('New Master Key was created')),
|
.then((_) => Logs().v('New Master Key was created')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -595,8 +590,10 @@ class Bootstrap {
|
||||||
if (state != BootstrapState.error) {
|
if (state != BootstrapState.error) {
|
||||||
_state = newState;
|
_state = newState;
|
||||||
}
|
}
|
||||||
if (onUpdate != null) {
|
|
||||||
onUpdate!();
|
final onUpdate_ = onUpdate;
|
||||||
|
if (onUpdate_ != null) {
|
||||||
|
onUpdate_();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -227,10 +227,9 @@ class KeyVerification {
|
||||||
if (deviceId == '*') {
|
if (deviceId == '*') {
|
||||||
_deviceId = payload['from_device']; // gotta set the real device id
|
_deviceId = payload['from_device']; // gotta set the real device id
|
||||||
// and broadcast the cancel to the other devices
|
// and broadcast the cancel to the other devices
|
||||||
final devices = client.userDeviceKeys.containsKey(userId)
|
final devices = List<DeviceKeys>.from(
|
||||||
? List<DeviceKeys>.from(
|
client.userDeviceKeys[userId]?.deviceKeys.values ??
|
||||||
client.userDeviceKeys[userId]!.deviceKeys.values)
|
Iterable.empty());
|
||||||
: List<DeviceKeys>.from([]);
|
|
||||||
devices.removeWhere(
|
devices.removeWhere(
|
||||||
(d) => {deviceId, client.deviceID}.contains(d.deviceId));
|
(d) => {deviceId, client.deviceID}.contains(d.deviceId));
|
||||||
final cancelPayload = <String, dynamic>{
|
final cancelPayload = <String, dynamic>{
|
||||||
|
|
@ -472,7 +471,8 @@ class KeyVerification {
|
||||||
Future<bool> Function(String, SignableKey) verifier) async {
|
Future<bool> Function(String, SignableKey) verifier) async {
|
||||||
_verifiedDevices = <SignableKey>[];
|
_verifiedDevices = <SignableKey>[];
|
||||||
|
|
||||||
if (!client.userDeviceKeys.containsKey(userId)) {
|
final userDeviceKey = client.userDeviceKeys[userId];
|
||||||
|
if (userDeviceKey == null) {
|
||||||
await cancel('m.key_mismatch');
|
await cancel('m.key_mismatch');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -480,7 +480,7 @@ class KeyVerification {
|
||||||
final keyId = entry.key;
|
final keyId = entry.key;
|
||||||
final verifyDeviceId = keyId.substring('ed25519:'.length);
|
final verifyDeviceId = keyId.substring('ed25519:'.length);
|
||||||
final keyInfo = entry.value;
|
final keyInfo = entry.value;
|
||||||
final key = client.userDeviceKeys[userId]!.getKey(verifyDeviceId);
|
final key = userDeviceKey.getKey(verifyDeviceId);
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
if (!(await verifier(keyInfo, key))) {
|
if (!(await verifier(keyInfo, key))) {
|
||||||
await cancel('m.key_mismatch');
|
await cancel('m.key_mismatch');
|
||||||
|
|
@ -619,8 +619,10 @@ class KeyVerification {
|
||||||
if (state != KeyVerificationState.error) {
|
if (state != KeyVerificationState.error) {
|
||||||
state = newState;
|
state = newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final onUpdate = this.onUpdate;
|
||||||
if (onUpdate != null) {
|
if (onUpdate != null) {
|
||||||
onUpdate!();
|
onUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -808,8 +810,8 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendAccept() async {
|
Future<void> _sendAccept() async {
|
||||||
sas = olm.SAS();
|
final sas = this.sas ??= olm.SAS();
|
||||||
commitment = _makeCommitment(sas!.get_pubkey(), startCanonicalJson);
|
commitment = _makeCommitment(sas.get_pubkey(), startCanonicalJson);
|
||||||
await request.send(EventTypes.KeyVerificationAccept, {
|
await request.send(EventTypes.KeyVerificationAccept, {
|
||||||
'method': type,
|
'method': type,
|
||||||
'key_agreement_protocol': keyAgreementProtocol,
|
'key_agreement_protocol': keyAgreementProtocol,
|
||||||
|
|
@ -906,9 +908,7 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod {
|
||||||
_calculateMac(encryption.fingerprintKey!, baseInfo + deviceKeyId);
|
_calculateMac(encryption.fingerprintKey!, baseInfo + deviceKeyId);
|
||||||
keyList.add(deviceKeyId);
|
keyList.add(deviceKeyId);
|
||||||
|
|
||||||
final masterKey = client.userDeviceKeys.containsKey(client.userID)
|
final masterKey = client.userDeviceKeys[client.userID]?.masterKey;
|
||||||
? client.userDeviceKeys[client.userID]!.masterKey
|
|
||||||
: null;
|
|
||||||
if (masterKey != null && masterKey.verified) {
|
if (masterKey != null && masterKey.verified) {
|
||||||
// we have our own master key verified, let's send it!
|
// we have our own master key verified, let's send it!
|
||||||
final masterKeyId = 'ed25519:${masterKey.publicKey}';
|
final masterKeyId = 'ed25519:${masterKey.publicKey}';
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ class SessionKey {
|
||||||
late Map<String, String> senderClaimedKeys;
|
late Map<String, String> senderClaimedKeys;
|
||||||
|
|
||||||
/// Sender curve25519 key
|
/// Sender curve25519 key
|
||||||
late String senderKey;
|
String senderKey;
|
||||||
|
|
||||||
/// Is this session valid?
|
/// Is this session valid?
|
||||||
bool get isValid => inboundGroupSession != null;
|
bool get isValid => inboundGroupSession != null;
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,10 @@ class Event extends MatrixEvent {
|
||||||
this.roomId = roomId ?? room?.id;
|
this.roomId = roomId ?? room?.id;
|
||||||
this.senderId = senderId;
|
this.senderId = senderId;
|
||||||
this.unsigned = unsigned;
|
this.unsigned = unsigned;
|
||||||
// synapse unfortunatley isn't following the spec and tosses the prev_content
|
// synapse unfortunatley isn't following the spec and tosses the prev_content
|
||||||
// into the unsigned block.
|
// into the unsigned block.
|
||||||
// Currently we are facing a very strange bug in web which is impossible to debug.
|
// 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.
|
// It may be because of this line so we put this in try-catch until we can fix it.
|
||||||
try {
|
try {
|
||||||
this.prevContent = (prevContent != null && prevContent.isNotEmpty)
|
this.prevContent = (prevContent != null && prevContent.isNotEmpty)
|
||||||
? prevContent
|
? prevContent
|
||||||
|
|
@ -111,21 +111,21 @@ class Event extends MatrixEvent {
|
||||||
? unsigned['prev_content']
|
? unsigned['prev_content']
|
||||||
: null;
|
: null;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// A strange bug in dart web makes this crash
|
// A strange bug in dart web makes this crash
|
||||||
}
|
}
|
||||||
this.stateKey = stateKey;
|
this.stateKey = stateKey;
|
||||||
this.originServerTs = originServerTs;
|
this.originServerTs = originServerTs;
|
||||||
|
|
||||||
// Mark event as failed to send if status is `sending` and event is older
|
// 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
|
// than the timeout. This should not happen with the deprecated Moor
|
||||||
// database!
|
// database!
|
||||||
if (status == 0 && room?.client?.database != null) {
|
if (status == 0 && room?.client?.database != null) {
|
||||||
// Age of this event in milliseconds
|
// Age of this event in milliseconds
|
||||||
final age = DateTime.now().millisecondsSinceEpoch -
|
final age = DateTime.now().millisecondsSinceEpoch -
|
||||||
originServerTs.millisecondsSinceEpoch;
|
originServerTs.millisecondsSinceEpoch;
|
||||||
|
|
||||||
if (age > room.client.sendMessageTimeoutSeconds * 1000) {
|
if (age > room.client.sendMessageTimeoutSeconds * 1000) {
|
||||||
// Update this event in database and open timelines
|
// Update this event in database and open timelines
|
||||||
final json = toJson();
|
final json = toJson();
|
||||||
json['unsigned'] ??= <String, dynamic>{};
|
json['unsigned'] ??= <String, dynamic>{};
|
||||||
json['unsigned'][messageSendingStatusKey] = -1;
|
json['unsigned'][messageSendingStatusKey] = -1;
|
||||||
|
|
@ -324,8 +324,8 @@ class Event extends MatrixEvent {
|
||||||
/// Try to send this event again. Only works with events of status -1.
|
/// Try to send this event again. Only works with events of status -1.
|
||||||
Future<String> sendAgain({String txid}) async {
|
Future<String> sendAgain({String txid}) async {
|
||||||
if (status != -1) return null;
|
if (status != -1) return null;
|
||||||
// we do not remove the event here. It will automatically be updated
|
// we do not remove the event here. It will automatically be updated
|
||||||
// in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
|
// in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
|
||||||
final newEventId = await room.sendEvent(
|
final newEventId = await room.sendEvent(
|
||||||
content,
|
content,
|
||||||
txid: txid ?? unsigned['transaction_id'] ?? eventId,
|
txid: txid ?? unsigned['transaction_id'] ?? eventId,
|
||||||
|
|
@ -420,7 +420,7 @@ class Event extends MatrixEvent {
|
||||||
return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl;
|
return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// size determined from an approximate 800x800 jpeg thumbnail with method=scale
|
// size determined from an approximate 800x800 jpeg thumbnail with method=scale
|
||||||
static const _minNoThumbSize = 80 * 1024;
|
static const _minNoThumbSize = 80 * 1024;
|
||||||
|
|
||||||
/// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
|
/// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
|
||||||
|
|
@ -449,14 +449,14 @@ class Event extends MatrixEvent {
|
||||||
final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
|
final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
|
||||||
final thisMxcUrl =
|
final thisMxcUrl =
|
||||||
useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
|
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 we have as method scale, we can return safely the original image, should it be small enough
|
||||||
if (getThumbnail &&
|
if (getThumbnail &&
|
||||||
method == ThumbnailMethod.scale &&
|
method == ThumbnailMethod.scale &&
|
||||||
thisInfoMap['size'] is int &&
|
thisInfoMap['size'] is int &&
|
||||||
thisInfoMap['size'] < minNoThumbSize) {
|
thisInfoMap['size'] < minNoThumbSize) {
|
||||||
getThumbnail = false;
|
getThumbnail = false;
|
||||||
}
|
}
|
||||||
// now generate the actual URLs
|
// now generate the actual URLs
|
||||||
if (getThumbnail) {
|
if (getThumbnail) {
|
||||||
return Uri.parse(thisMxcUrl).getThumbnail(
|
return Uri.parse(thisMxcUrl).getThumbnail(
|
||||||
room.client,
|
room.client,
|
||||||
|
|
@ -480,7 +480,7 @@ class Event extends MatrixEvent {
|
||||||
throw "This event hasn't any attachment or thumbnail.";
|
throw "This event hasn't any attachment or thumbnail.";
|
||||||
}
|
}
|
||||||
getThumbnail = mxcUrl != attachmentMxcUrl;
|
getThumbnail = mxcUrl != attachmentMxcUrl;
|
||||||
// Is this file storeable?
|
// Is this file storeable?
|
||||||
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
|
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
|
||||||
final storeable = room.client.database != null &&
|
final storeable = room.client.database != null &&
|
||||||
thisInfoMap['size'] is int &&
|
thisInfoMap['size'] is int &&
|
||||||
|
|
@ -517,7 +517,7 @@ class Event extends MatrixEvent {
|
||||||
|
|
||||||
Uint8List uint8list;
|
Uint8List uint8list;
|
||||||
|
|
||||||
// Is this file storeable?
|
// Is this file storeable?
|
||||||
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
|
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
|
||||||
var storeable = room.client.database != null &&
|
var storeable = room.client.database != null &&
|
||||||
thisInfoMap['size'] is int &&
|
thisInfoMap['size'] is int &&
|
||||||
|
|
@ -527,7 +527,7 @@ class Event extends MatrixEvent {
|
||||||
uint8list = await room.client.database.getFile(mxcUrl);
|
uint8list = await room.client.database.getFile(mxcUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the file
|
// Download the file
|
||||||
if (uint8list == null) {
|
if (uint8list == null) {
|
||||||
downloadCallback ??= (Uri url) async {
|
downloadCallback ??= (Uri url) async {
|
||||||
return (await http.get(url)).bodyBytes;
|
return (await http.get(url)).bodyBytes;
|
||||||
|
|
@ -541,7 +541,7 @@ class Event extends MatrixEvent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the file
|
// Decrypt the file
|
||||||
if (isEncrypted) {
|
if (isEncrypted) {
|
||||||
final fileMap =
|
final fileMap =
|
||||||
getThumbnail ? infoMap['thumbnail_file'] : content['file'];
|
getThumbnail ? infoMap['thumbnail_file'] : content['file'];
|
||||||
|
|
@ -579,10 +579,10 @@ class Event extends MatrixEvent {
|
||||||
}
|
}
|
||||||
var body = plaintextBody ? this.plaintextBody : this.body;
|
var body = plaintextBody ? this.plaintextBody : this.body;
|
||||||
|
|
||||||
// we need to know if the message is an html message to be able to determine
|
// we need to know if the message is an html message to be able to determine
|
||||||
// if we need to strip the reply fallback.
|
// if we need to strip the reply fallback.
|
||||||
var htmlMessage = content['format'] != 'org.matrix.custom.html';
|
var htmlMessage = content['format'] != 'org.matrix.custom.html';
|
||||||
// If we have an edit, we want to operate on the new content
|
// If we have an edit, we want to operate on the new content
|
||||||
if (hideEdit &&
|
if (hideEdit &&
|
||||||
relationshipType == RelationshipTypes.edit &&
|
relationshipType == RelationshipTypes.edit &&
|
||||||
content.tryGet<Map<String, dynamic>>('m.new_content') != null) {
|
content.tryGet<Map<String, dynamic>>('m.new_content') != null) {
|
||||||
|
|
@ -600,9 +600,9 @@ class Event extends MatrixEvent {
|
||||||
body;
|
body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Hide reply fallback
|
// Hide reply fallback
|
||||||
// Be sure that the plaintextBody already stripped teh reply fallback,
|
// Be sure that the plaintextBody already stripped teh reply fallback,
|
||||||
// if the message is formatted
|
// if the message is formatted
|
||||||
if (hideReply && (!plaintextBody || htmlMessage)) {
|
if (hideReply && (!plaintextBody || htmlMessage)) {
|
||||||
body = body.replaceFirst(
|
body = body.replaceFirst(
|
||||||
RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
|
RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
|
||||||
|
|
@ -613,7 +613,7 @@ class Event extends MatrixEvent {
|
||||||
localizedBody = callback(this, i18n, body);
|
localizedBody = callback(this, i18n, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the sender name prefix
|
// Add the sender name prefix
|
||||||
if (withSenderNamePrefix &&
|
if (withSenderNamePrefix &&
|
||||||
type == EventTypes.Message &&
|
type == EventTypes.Message &&
|
||||||
textOnlyMessageTypes.contains(messageType)) {
|
textOnlyMessageTypes.contains(messageType)) {
|
||||||
|
|
@ -691,13 +691,13 @@ class Event extends MatrixEvent {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
|
if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
|
||||||
// alright, we have an edit
|
// alright, we have an edit
|
||||||
final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit)
|
final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit)
|
||||||
// we only allow edits made by the original author themself
|
// we only allow edits made by the original author themself
|
||||||
.where((e) => e.senderId == senderId && e.type == EventTypes.Message)
|
.where((e) => e.senderId == senderId && e.type == EventTypes.Message)
|
||||||
.toList();
|
.toList();
|
||||||
// we need to check again if it isn't empty, as we potentially removed all
|
// we need to check again if it isn't empty, as we potentially removed all
|
||||||
// aggregated edits
|
// aggregated edits
|
||||||
if (allEditEvents.isNotEmpty) {
|
if (allEditEvents.isNotEmpty) {
|
||||||
allEditEvents.sort((a, b) => a.originServerTs.millisecondsSinceEpoch -
|
allEditEvents.sort((a, b) => a.originServerTs.millisecondsSinceEpoch -
|
||||||
b.originServerTs.millisecondsSinceEpoch >
|
b.originServerTs.millisecondsSinceEpoch >
|
||||||
|
|
@ -705,7 +705,7 @@ class Event extends MatrixEvent {
|
||||||
? 1
|
? 1
|
||||||
: -1);
|
: -1);
|
||||||
final rawEvent = allEditEvents.last.toJson();
|
final rawEvent = allEditEvents.last.toJson();
|
||||||
// update the content of the new event to render
|
// update the content of the new event to render
|
||||||
if (rawEvent['content']['m.new_content'] is Map) {
|
if (rawEvent['content']['m.new_content'] is Map) {
|
||||||
rawEvent['content'] = rawEvent['content']['m.new_content'];
|
rawEvent['content'] = rawEvent['content']['m.new_content'];
|
||||||
}
|
}
|
||||||
|
|
@ -720,16 +720,16 @@ class Event extends MatrixEvent {
|
||||||
content['format'] == 'org.matrix.custom.html' &&
|
content['format'] == 'org.matrix.custom.html' &&
|
||||||
content['formatted_body'] is String;
|
content['formatted_body'] is String;
|
||||||
|
|
||||||
// regexes to fetch the number of emotes, including emoji, and if the message consists of only those
|
// 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:
|
// 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}]?
|
// (?:\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
|
// 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)[^>]*>
|
// 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:
|
// now we combind the two to have four regexes:
|
||||||
// 1. are there only emoji, or whitespace
|
// 1. are there only emoji, or whitespace
|
||||||
// 2. are there only emoji, emotes, or whitespace
|
// 2. are there only emoji, emotes, or whitespace
|
||||||
// 3. count number of emoji
|
// 3. count number of emoji
|
||||||
// 4- count number of emoji or emotes
|
// 4- count number of emoji or emotes
|
||||||
static final RegExp _onlyEmojiRegex = RegExp(
|
static final RegExp _onlyEmojiRegex = RegExp(
|
||||||
r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$',
|
r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
|
|
|
||||||
|
|
@ -249,14 +249,9 @@ abstract class SignableKey extends MatrixSignableKey {
|
||||||
if (otherUserId == userId && keyId == identifier) {
|
if (otherUserId == userId && keyId == identifier) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
SignableKey? key;
|
|
||||||
if (client.userDeviceKeys[otherUserId]!.deviceKeys.containsKey(keyId)) {
|
|
||||||
key = client.userDeviceKeys[otherUserId]!.deviceKeys[keyId];
|
|
||||||
} else if (client.userDeviceKeys[otherUserId]!.crossSigningKeys
|
|
||||||
.containsKey(keyId)) {
|
|
||||||
key = client.userDeviceKeys[otherUserId]!.crossSigningKeys[keyId];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
final key = client.userDeviceKeys[otherUserId]?.deviceKeys[keyId] ??
|
||||||
|
client.userDeviceKeys[otherUserId]?.crossSigningKeys[keyId];
|
||||||
if (key == null) {
|
if (key == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -272,25 +267,22 @@ abstract class SignableKey extends MatrixSignableKey {
|
||||||
}
|
}
|
||||||
var haveValidSignature = false;
|
var haveValidSignature = false;
|
||||||
var gotSignatureFromCache = false;
|
var gotSignatureFromCache = false;
|
||||||
if (validSignatures != null &&
|
if (validSignatures?[otherUserId][fullKeyId] == true) {
|
||||||
validSignatures!.containsKey(otherUserId) &&
|
haveValidSignature = true;
|
||||||
validSignatures![otherUserId].containsKey(fullKeyId)) {
|
gotSignatureFromCache = true;
|
||||||
if (validSignatures![otherUserId][fullKeyId] == true) {
|
} else if (validSignatures?[otherUserId][fullKeyId] == false) {
|
||||||
haveValidSignature = true;
|
haveValidSignature = false;
|
||||||
gotSignatureFromCache = true;
|
gotSignatureFromCache = true;
|
||||||
} else if (validSignatures![otherUserId][fullKeyId] == false) {
|
|
||||||
haveValidSignature = false;
|
|
||||||
gotSignatureFromCache = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!gotSignatureFromCache && key.ed25519Key != null) {
|
if (!gotSignatureFromCache && key.ed25519Key != null) {
|
||||||
// validate the signature manually
|
// validate the signature manually
|
||||||
haveValidSignature = _verifySignature(key.ed25519Key!, signature);
|
haveValidSignature = _verifySignature(key.ed25519Key!, signature);
|
||||||
validSignatures ??= <String, dynamic>{};
|
final validSignatures = this.validSignatures ??= <String, dynamic>{};
|
||||||
if (!validSignatures!.containsKey(otherUserId)) {
|
if (!validSignatures.containsKey(otherUserId)) {
|
||||||
validSignatures![otherUserId] = <String, dynamic>{};
|
validSignatures[otherUserId] = <String, dynamic>{};
|
||||||
}
|
}
|
||||||
validSignatures![otherUserId][fullKeyId] = haveValidSignature;
|
validSignatures[otherUserId][fullKeyId] = haveValidSignature;
|
||||||
}
|
}
|
||||||
if (!haveValidSignature) {
|
if (!haveValidSignature) {
|
||||||
// no valid signature, this key is useless
|
// no valid signature, this key is useless
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ dependencies:
|
||||||
js: ^0.6.3
|
js: ^0.6.3
|
||||||
slugify: ^2.0.0
|
slugify: ^2.0.0
|
||||||
html: ^0.15.0
|
html: ^0.15.0
|
||||||
collection: ^1.15.0-nullsafety.4
|
collection: ^1.15.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
pedantic: ^1.11.0
|
pedantic: ^1.11.0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue