feature: Upload to online key backup
This commit is contained in:
parent
8899f4c677
commit
99d536b14f
|
@ -63,6 +63,8 @@ class Encryption {
|
||||||
|
|
||||||
Future<void> init(String olmAccount) async {
|
Future<void> init(String olmAccount) async {
|
||||||
await olmManager.init(olmAccount);
|
await olmManager.init(olmAccount);
|
||||||
|
_backgroundTasksRunning = true;
|
||||||
|
_backgroundTasks(); // start the background tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleDeviceOneTimeKeysCount(Map<String, int> countJson) {
|
void handleDeviceOneTimeKeysCount(Map<String, int> countJson) {
|
||||||
|
@ -307,10 +309,25 @@ class Encryption {
|
||||||
return await olmManager.encryptToDeviceMessage(deviceKeys, type, payload);
|
return await olmManager.encryptToDeviceMessage(deviceKeys, type, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// this method is responsible for all background tasks, such as uploading online key backups
|
||||||
|
bool _backgroundTasksRunning = true;
|
||||||
|
void _backgroundTasks() {
|
||||||
|
if (!_backgroundTasksRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyManager.backgroundTasks();
|
||||||
|
|
||||||
|
if (_backgroundTasksRunning) {
|
||||||
|
Timer(Duration(seconds: 10), _backgroundTasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
keyManager.dispose();
|
keyManager.dispose();
|
||||||
olmManager.dispose();
|
olmManager.dispose();
|
||||||
keyVerificationManager.dispose();
|
keyVerificationManager.dispose();
|
||||||
|
_backgroundTasksRunning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,9 @@ import './utils/outbound_group_session.dart';
|
||||||
import './utils/session_key.dart';
|
import './utils/session_key.dart';
|
||||||
import '../famedlysdk.dart';
|
import '../famedlysdk.dart';
|
||||||
import '../matrix_api.dart';
|
import '../matrix_api.dart';
|
||||||
|
import '../src/database/database.dart';
|
||||||
import '../src/utils/logs.dart';
|
import '../src/utils/logs.dart';
|
||||||
|
import '../src/utils/run_in_background.dart';
|
||||||
|
|
||||||
const MEGOLM_KEY = 'm.megolm_backup.v1';
|
const MEGOLM_KEY = 'm.megolm_backup.v1';
|
||||||
|
|
||||||
|
@ -71,18 +73,14 @@ class KeyManager {
|
||||||
|
|
||||||
void setInboundGroupSession(String roomId, String sessionId, String senderKey,
|
void setInboundGroupSession(String roomId, String sessionId, String senderKey,
|
||||||
Map<String, dynamic> content,
|
Map<String, dynamic> content,
|
||||||
{bool forwarded = false, Map<String, String> senderClaimedKeys}) {
|
{bool forwarded = false,
|
||||||
|
Map<String, String> senderClaimedKeys,
|
||||||
|
bool uploaded = false}) {
|
||||||
senderClaimedKeys ??= <String, String>{};
|
senderClaimedKeys ??= <String, String>{};
|
||||||
if (!senderClaimedKeys.containsKey('ed25519')) {
|
if (!senderClaimedKeys.containsKey('ed25519')) {
|
||||||
DeviceKeys device;
|
final device = client.getUserDeviceKeysByCurve25519Key(senderKey);
|
||||||
for (final user in client.userDeviceKeys.values) {
|
if (device != null) {
|
||||||
device = user.deviceKeys.values.firstWhere(
|
senderClaimedKeys['ed25519'] = device.ed25519Key;
|
||||||
(e) => e.curve25519Key == senderKey,
|
|
||||||
orElse: () => null);
|
|
||||||
if (device != null) {
|
|
||||||
senderClaimedKeys['ed25519'] = device.ed25519Key;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final oldSession =
|
final oldSession =
|
||||||
|
@ -109,6 +107,8 @@ class KeyManager {
|
||||||
content: content,
|
content: content,
|
||||||
inboundGroupSession: inboundGroupSession,
|
inboundGroupSession: inboundGroupSession,
|
||||||
indexes: {},
|
indexes: {},
|
||||||
|
roomId: roomId,
|
||||||
|
sessionId: sessionId,
|
||||||
key: client.userID,
|
key: client.userID,
|
||||||
senderKey: senderKey,
|
senderKey: senderKey,
|
||||||
senderClaimedKeys: senderClaimedKeys,
|
senderClaimedKeys: senderClaimedKeys,
|
||||||
|
@ -132,7 +132,8 @@ class KeyManager {
|
||||||
_inboundGroupSessions[roomId] = <String, SessionKey>{};
|
_inboundGroupSessions[roomId] = <String, SessionKey>{};
|
||||||
}
|
}
|
||||||
_inboundGroupSessions[roomId][sessionId] = newSession;
|
_inboundGroupSessions[roomId][sessionId] = newSession;
|
||||||
client.database?.storeInboundGroupSession(
|
client.database
|
||||||
|
?.storeInboundGroupSession(
|
||||||
client.id,
|
client.id,
|
||||||
roomId,
|
roomId,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
@ -141,9 +142,13 @@ class KeyManager {
|
||||||
json.encode({}),
|
json.encode({}),
|
||||||
senderKey,
|
senderKey,
|
||||||
json.encode(senderClaimedKeys),
|
json.encode(senderClaimedKeys),
|
||||||
);
|
)
|
||||||
// Note to self: When adding key-backup that needs to be unawaited(), else
|
?.then((_) {
|
||||||
// we might accidentally end up with http requests inside of the sync loop
|
if (uploaded) {
|
||||||
|
client.database
|
||||||
|
.markInboundGroupSessionAsUploaded(client.id, roomId, sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
// TODO: somehow try to decrypt last message again
|
// TODO: somehow try to decrypt last message again
|
||||||
final room = client.getRoomById(roomId);
|
final room = client.getRoomById(roomId);
|
||||||
if (room != null) {
|
if (room != null) {
|
||||||
|
@ -410,7 +415,8 @@ class KeyManager {
|
||||||
forwarded: true,
|
forwarded: true,
|
||||||
senderClaimedKeys: decrypted['sender_claimed_keys'] != null
|
senderClaimedKeys: decrypted['sender_claimed_keys'] != null
|
||||||
? Map<String, String>.from(decrypted['sender_claimed_keys'])
|
? Map<String, String>.from(decrypted['sender_claimed_keys'])
|
||||||
: null);
|
: null,
|
||||||
|
uploaded: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -492,6 +498,79 @@ class KeyManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isUploadingKeys = false;
|
||||||
|
Future<void> backgroundTasks() async {
|
||||||
|
if (_isUploadingKeys || client.database == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_isUploadingKeys = true;
|
||||||
|
try {
|
||||||
|
if (!(await isCached())) {
|
||||||
|
return; // we can't backup anyways
|
||||||
|
}
|
||||||
|
final dbSessions =
|
||||||
|
await client.database.getInboundGroupSessionsToUpload().get();
|
||||||
|
if (dbSessions.isEmpty) {
|
||||||
|
return; // nothing to do
|
||||||
|
}
|
||||||
|
final privateKey =
|
||||||
|
base64.decode(await encryption.ssss.getCached(MEGOLM_KEY));
|
||||||
|
// decryption is needed to calculate the public key and thus see if the claimed information is in fact valid
|
||||||
|
final decryption = olm.PkDecryption();
|
||||||
|
final info = await client.getRoomKeysBackup();
|
||||||
|
String backupPubKey;
|
||||||
|
try {
|
||||||
|
backupPubKey = decryption.init_with_private_key(privateKey);
|
||||||
|
|
||||||
|
if (backupPubKey == null ||
|
||||||
|
info.algorithm != RoomKeysAlgorithmType.v1Curve25519AesSha2 ||
|
||||||
|
info.authData['public_key'] != backupPubKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final args = _GenerateUploadKeysArgs(
|
||||||
|
pubkey: backupPubKey,
|
||||||
|
dbSessions: <_DbInboundGroupSessionBundle>[],
|
||||||
|
userId: client.userID,
|
||||||
|
);
|
||||||
|
// we need to calculate verified beforehand, as else we pass a closure to an isolate
|
||||||
|
// with 500 keys they do, however, noticably block the UI, which is why we give brief async suspentions in here
|
||||||
|
// so that the event loop can progress
|
||||||
|
var i = 0;
|
||||||
|
for (final dbSession in dbSessions) {
|
||||||
|
final device =
|
||||||
|
client.getUserDeviceKeysByCurve25519Key(dbSession.senderKey);
|
||||||
|
args.dbSessions.add(_DbInboundGroupSessionBundle(
|
||||||
|
dbSession: dbSession,
|
||||||
|
verified: device?.verified ?? false,
|
||||||
|
));
|
||||||
|
i++;
|
||||||
|
if (i > 10) {
|
||||||
|
await Future.delayed(Duration(milliseconds: 1));
|
||||||
|
i = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final roomKeys =
|
||||||
|
await runInBackground<RoomKeys, _GenerateUploadKeysArgs>(
|
||||||
|
_generateUploadKeys, args);
|
||||||
|
Logs.info('[Key Manager] Uploading ${dbSessions.length} room keys...');
|
||||||
|
// upload the payload...
|
||||||
|
await client.storeRoomKeys(info.version, roomKeys);
|
||||||
|
// and now finally mark all the keys as uploaded
|
||||||
|
// no need to optimze this, as we only run it so seldomly and almost never with many keys at once
|
||||||
|
for (final dbSession in dbSessions) {
|
||||||
|
await client.database.markInboundGroupSessionAsUploaded(
|
||||||
|
client.id, dbSession.roomId, dbSession.sessionId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
decryption.free();
|
||||||
|
}
|
||||||
|
} catch (e, s) {
|
||||||
|
Logs.error('[Key Manager] Error uploading room keys: ' + e.toString(), s);
|
||||||
|
} finally {
|
||||||
|
_isUploadingKeys = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle an incoming to_device event that is related to key sharing
|
/// Handle an incoming to_device event that is related to key sharing
|
||||||
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
|
||||||
if (event.type == 'm.room_key_request') {
|
if (event.type == 'm.room_key_request') {
|
||||||
|
@ -725,3 +804,67 @@ class RoomKeyRequest extends ToDeviceEvent {
|
||||||
keyManager.incomingShareRequests.remove(request.requestId);
|
keyManager.incomingShareRequests.remove(request.requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RoomKeys _generateUploadKeys(_GenerateUploadKeysArgs args) {
|
||||||
|
final enc = olm.PkEncryption();
|
||||||
|
try {
|
||||||
|
enc.set_recipient_key(args.pubkey);
|
||||||
|
// first we generate the payload to upload all the session keys in this chunk
|
||||||
|
final roomKeys = RoomKeys();
|
||||||
|
for (final dbSession in args.dbSessions) {
|
||||||
|
final sess = SessionKey.fromDb(dbSession.dbSession, args.userId);
|
||||||
|
if (!sess.isValid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// create the room if it doesn't exist
|
||||||
|
if (!roomKeys.rooms.containsKey(sess.roomId)) {
|
||||||
|
roomKeys.rooms[sess.roomId] = RoomKeysRoom();
|
||||||
|
}
|
||||||
|
// generate the encrypted content
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'algorithm': 'm.megolm.v1.aes-sha2',
|
||||||
|
'forwarding_curve25519_key_chain': sess.forwardingCurve25519KeyChain,
|
||||||
|
'sender_key': sess.senderKey,
|
||||||
|
'sender_clencaimed_keys': sess.senderClaimedKeys,
|
||||||
|
'session_key': sess.inboundGroupSession
|
||||||
|
.export_session(sess.inboundGroupSession.first_known_index()),
|
||||||
|
};
|
||||||
|
// encrypt the content
|
||||||
|
final encrypted = enc.encrypt(json.encode(payload));
|
||||||
|
// fetch the device, if available...
|
||||||
|
//final device = args.client.getUserDeviceKeysByCurve25519Key(sess.senderKey);
|
||||||
|
// aaaand finally add the session key to our payload
|
||||||
|
roomKeys.rooms[sess.roomId].sessions[sess.sessionId] = RoomKeysSingleKey(
|
||||||
|
firstMessageIndex: sess.inboundGroupSession.first_known_index(),
|
||||||
|
forwardedCount: sess.forwardingCurve25519KeyChain.length,
|
||||||
|
isVerified: dbSession.verified, //device?.verified ?? false,
|
||||||
|
sessionData: {
|
||||||
|
'ephemeral': encrypted.ephemeral,
|
||||||
|
'ciphertext': encrypted.ciphertext,
|
||||||
|
'mac': encrypted.mac,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return roomKeys;
|
||||||
|
} catch (e, s) {
|
||||||
|
Logs.error('[Key Manager] Error generating payload ' + e.toString(), s);
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
enc.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DbInboundGroupSessionBundle {
|
||||||
|
_DbInboundGroupSessionBundle({this.dbSession, this.verified});
|
||||||
|
|
||||||
|
DbInboundGroupSession dbSession;
|
||||||
|
bool verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GenerateUploadKeysArgs {
|
||||||
|
_GenerateUploadKeysArgs({this.pubkey, this.dbSessions, this.userId});
|
||||||
|
|
||||||
|
String pubkey;
|
||||||
|
List<_DbInboundGroupSessionBundle> dbSessions;
|
||||||
|
String userId;
|
||||||
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ import 'package:password_hash/password_hash.dart';
|
||||||
|
|
||||||
import '../famedlysdk.dart';
|
import '../famedlysdk.dart';
|
||||||
import '../matrix_api.dart';
|
import '../matrix_api.dart';
|
||||||
|
import '../src/database/database.dart';
|
||||||
import '../src/utils/logs.dart';
|
import '../src/utils/logs.dart';
|
||||||
import 'encryption.dart';
|
import 'encryption.dart';
|
||||||
|
|
||||||
|
@ -48,8 +49,15 @@ class SSSS {
|
||||||
Client get client => encryption.client;
|
Client get client => encryption.client;
|
||||||
final pendingShareRequests = <String, _ShareRequest>{};
|
final pendingShareRequests = <String, _ShareRequest>{};
|
||||||
final _validators = <String, Future<bool> Function(String)>{};
|
final _validators = <String, Future<bool> Function(String)>{};
|
||||||
|
final Map<String, DbSSSSCache> _cache = <String, DbSSSSCache>{};
|
||||||
SSSS(this.encryption);
|
SSSS(this.encryption);
|
||||||
|
|
||||||
|
// for testing
|
||||||
|
Future<void> clearCache() async {
|
||||||
|
await client.database?.clearSSSSCache(client.id);
|
||||||
|
_cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
static _DerivedKeys deriveKeys(Uint8List key, String name) {
|
static _DerivedKeys deriveKeys(Uint8List key, String name) {
|
||||||
final zerosalt = Uint8List(8);
|
final zerosalt = Uint8List(8);
|
||||||
final prk = Hmac(sha256, zerosalt).convert(key);
|
final prk = Hmac(sha256, zerosalt).convert(key);
|
||||||
|
@ -173,16 +181,22 @@ class SSSS {
|
||||||
if (client.database == null) {
|
if (client.database == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// check if it is still valid
|
||||||
|
final keys = keyIdsFromType(type);
|
||||||
|
final isValid = (dbEntry) =>
|
||||||
|
keys.contains(dbEntry.keyId) &&
|
||||||
|
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(client.id, type);
|
final ret = await client.database.getSSSSCache(client.id, type);
|
||||||
if (ret == null) {
|
if (ret == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// check if it is still valid
|
if (isValid(ret)) {
|
||||||
final keys = keyIdsFromType(type);
|
_cache[type] = ret;
|
||||||
if (keys.contains(ret.keyId) &&
|
|
||||||
client.accountData[type].content['encrypted'][ret.keyId]
|
|
||||||
['ciphertext'] ==
|
|
||||||
ret.ciphertext) {
|
|
||||||
return ret.content;
|
return ret.content;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -37,12 +37,16 @@ class SessionKey {
|
||||||
Map<String, String> senderClaimedKeys;
|
Map<String, String> senderClaimedKeys;
|
||||||
String senderKey;
|
String senderKey;
|
||||||
bool get isValid => inboundGroupSession != null;
|
bool get isValid => inboundGroupSession != null;
|
||||||
|
String roomId;
|
||||||
|
String sessionId;
|
||||||
|
|
||||||
SessionKey(
|
SessionKey(
|
||||||
{this.content,
|
{this.content,
|
||||||
this.inboundGroupSession,
|
this.inboundGroupSession,
|
||||||
this.key,
|
this.key,
|
||||||
this.indexes,
|
this.indexes,
|
||||||
|
this.roomId,
|
||||||
|
this.sessionId,
|
||||||
String senderKey,
|
String senderKey,
|
||||||
Map<String, String> senderClaimedKeys}) {
|
Map<String, String> senderClaimedKeys}) {
|
||||||
_setSenderKey(senderKey);
|
_setSenderKey(senderKey);
|
||||||
|
@ -59,6 +63,8 @@ class SessionKey {
|
||||||
indexes = parsedIndexes != null
|
indexes = parsedIndexes != null
|
||||||
? Map<String, int>.from(parsedIndexes)
|
? Map<String, int>.from(parsedIndexes)
|
||||||
: <String, int>{};
|
: <String, int>{};
|
||||||
|
roomId = dbEntry.roomId;
|
||||||
|
sessionId = dbEntry.sessionId;
|
||||||
_setSenderKey(dbEntry.senderKey);
|
_setSenderKey(dbEntry.senderKey);
|
||||||
_setSenderClaimedKeys(Map<String, String>.from(parsedSenderClaimedKeys));
|
_setSenderClaimedKeys(Map<String, String>.from(parsedSenderClaimedKeys));
|
||||||
|
|
||||||
|
|
|
@ -2062,7 +2062,7 @@ class MatrixApi {
|
||||||
return RoomKeysRoom.fromJson(ret);
|
return RoomKeysRoom.fromJson(ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes room ekys for a room
|
/// Deletes room keys for a room
|
||||||
/// https://matrix.org/docs/spec/client_server/unstable#delete-matrix-client-r0-room-keys-keys-roomid
|
/// https://matrix.org/docs/spec/client_server/unstable#delete-matrix-client-r0-room-keys-keys-roomid
|
||||||
Future<RoomKeysUpdateResponse> deleteRoomKeysRoom(
|
Future<RoomKeysUpdateResponse> deleteRoomKeysRoom(
|
||||||
String roomId, String version) async {
|
String roomId, String version) async {
|
||||||
|
|
|
@ -22,6 +22,12 @@ class RoomKeysSingleKey {
|
||||||
bool isVerified;
|
bool isVerified;
|
||||||
Map<String, dynamic> sessionData;
|
Map<String, dynamic> sessionData;
|
||||||
|
|
||||||
|
RoomKeysSingleKey(
|
||||||
|
{this.firstMessageIndex,
|
||||||
|
this.forwardedCount,
|
||||||
|
this.isVerified,
|
||||||
|
this.sessionData});
|
||||||
|
|
||||||
RoomKeysSingleKey.fromJson(Map<String, dynamic> json) {
|
RoomKeysSingleKey.fromJson(Map<String, dynamic> json) {
|
||||||
firstMessageIndex = json['first_message_index'];
|
firstMessageIndex = json['first_message_index'];
|
||||||
forwardedCount = json['forwarded_count'];
|
forwardedCount = json['forwarded_count'];
|
||||||
|
@ -42,6 +48,10 @@ class RoomKeysSingleKey {
|
||||||
class RoomKeysRoom {
|
class RoomKeysRoom {
|
||||||
Map<String, RoomKeysSingleKey> sessions;
|
Map<String, RoomKeysSingleKey> sessions;
|
||||||
|
|
||||||
|
RoomKeysRoom({this.sessions}) {
|
||||||
|
sessions ??= <String, RoomKeysSingleKey>{};
|
||||||
|
}
|
||||||
|
|
||||||
RoomKeysRoom.fromJson(Map<String, dynamic> json) {
|
RoomKeysRoom.fromJson(Map<String, dynamic> json) {
|
||||||
sessions = (json['sessions'] as Map)
|
sessions = (json['sessions'] as Map)
|
||||||
.map((k, v) => MapEntry(k, RoomKeysSingleKey.fromJson(v)));
|
.map((k, v) => MapEntry(k, RoomKeysSingleKey.fromJson(v)));
|
||||||
|
@ -57,6 +67,10 @@ class RoomKeysRoom {
|
||||||
class RoomKeys {
|
class RoomKeys {
|
||||||
Map<String, RoomKeysRoom> rooms;
|
Map<String, RoomKeysRoom> rooms;
|
||||||
|
|
||||||
|
RoomKeys({this.rooms}) {
|
||||||
|
rooms ??= <String, RoomKeysRoom>{};
|
||||||
|
}
|
||||||
|
|
||||||
RoomKeys.fromJson(Map<String, dynamic> json) {
|
RoomKeys.fromJson(Map<String, dynamic> json) {
|
||||||
rooms = (json['rooms'] as Map)
|
rooms = (json['rooms'] as Map)
|
||||||
.map((k, v) => MapEntry(k, RoomKeysRoom.fromJson(v)));
|
.map((k, v) => MapEntry(k, RoomKeysRoom.fromJson(v)));
|
||||||
|
|
|
@ -615,6 +615,7 @@ class Client extends MatrixApi {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
encryption?.dispose();
|
||||||
encryption =
|
encryption =
|
||||||
Encryption(client: this, enableE2eeRecovery: enableE2eeRecovery);
|
Encryption(client: this, enableE2eeRecovery: enableE2eeRecovery);
|
||||||
await encryption.init(olmAccount);
|
await encryption.init(olmAccount);
|
||||||
|
@ -1165,6 +1166,18 @@ class Client extends MatrixApi {
|
||||||
Map<String, DeviceKeysList> get userDeviceKeys => _userDeviceKeys;
|
Map<String, DeviceKeysList> get userDeviceKeys => _userDeviceKeys;
|
||||||
Map<String, DeviceKeysList> _userDeviceKeys = {};
|
Map<String, DeviceKeysList> _userDeviceKeys = {};
|
||||||
|
|
||||||
|
/// Gets user device keys by its curve25519 key. Returns null if it isn't found
|
||||||
|
DeviceKeys getUserDeviceKeysByCurve25519Key(String senderKey) {
|
||||||
|
for (final user in userDeviceKeys.values) {
|
||||||
|
final device = user.deviceKeys.values
|
||||||
|
.firstWhere((e) => e.curve25519Key == senderKey, orElse: () => null);
|
||||||
|
if (device != null) {
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Set<String>> _getUserIdsInEncryptedRooms() async {
|
Future<Set<String>> _getUserIdsInEncryptedRooms() async {
|
||||||
var userIds = <String>{};
|
var userIds = <String>{};
|
||||||
for (var i = 0; i < rooms.length; i++) {
|
for (var i = 0; i < rooms.length; i++) {
|
||||||
|
@ -1493,6 +1506,8 @@ class Client extends MatrixApi {
|
||||||
}
|
}
|
||||||
if (closeDatabase) await database?.close();
|
if (closeDatabase) await database?.close();
|
||||||
database = null;
|
database = null;
|
||||||
|
encryption?.dispose();
|
||||||
|
encryption = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5873,6 +5873,27 @@ abstract class _$Database extends GeneratedDatabase {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Selectable<DbInboundGroupSession> getInboundGroupSessionsToUpload() {
|
||||||
|
return customSelect(
|
||||||
|
'SELECT * FROM inbound_group_sessions WHERE uploaded = false LIMIT 500',
|
||||||
|
variables: [],
|
||||||
|
readsFrom: {inboundGroupSessions}).map(_rowToDbInboundGroupSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> markInboundGroupSessionAsUploaded(
|
||||||
|
int client_id, String room_id, String session_id) {
|
||||||
|
return customUpdate(
|
||||||
|
'UPDATE inbound_group_sessions SET uploaded = true WHERE client_id = :client_id AND room_id = :room_id AND session_id = :session_id',
|
||||||
|
variables: [
|
||||||
|
Variable.withInt(client_id),
|
||||||
|
Variable.withString(room_id),
|
||||||
|
Variable.withString(session_id)
|
||||||
|
],
|
||||||
|
updates: {inboundGroupSessions},
|
||||||
|
updateKind: UpdateKind.update,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> storeUserDeviceKeysInfo(
|
Future<int> storeUserDeviceKeysInfo(
|
||||||
int client_id, String user_id, bool outdated) {
|
int client_id, String user_id, bool outdated) {
|
||||||
return customInsert(
|
return customInsert(
|
||||||
|
|
|
@ -191,6 +191,8 @@ dbGetInboundGroupSessionKeys: SELECT * FROM inbound_group_sessions WHERE client_
|
||||||
getAllInboundGroupSessions: SELECT * FROM inbound_group_sessions WHERE client_id = :client_id;
|
getAllInboundGroupSessions: SELECT * FROM inbound_group_sessions WHERE client_id = :client_id;
|
||||||
storeInboundGroupSession: INSERT OR REPLACE INTO inbound_group_sessions (client_id, room_id, session_id, pickle, content, indexes, sender_key, sender_claimed_keys) VALUES (:client_id, :room_id, :session_id, :pickle, :content, :indexes, :sender_key, :sender_claimed_keys);
|
storeInboundGroupSession: INSERT OR REPLACE INTO inbound_group_sessions (client_id, room_id, session_id, pickle, content, indexes, sender_key, sender_claimed_keys) VALUES (:client_id, :room_id, :session_id, :pickle, :content, :indexes, :sender_key, :sender_claimed_keys);
|
||||||
updateInboundGroupSessionIndexes: UPDATE inbound_group_sessions SET indexes = :indexes WHERE client_id = :client_id AND room_id = :room_id AND session_id = :session_id;
|
updateInboundGroupSessionIndexes: UPDATE inbound_group_sessions SET indexes = :indexes WHERE client_id = :client_id AND room_id = :room_id AND session_id = :session_id;
|
||||||
|
getInboundGroupSessionsToUpload: SELECT * FROM inbound_group_sessions WHERE uploaded = false LIMIT 500;
|
||||||
|
markInboundGroupSessionAsUploaded: UPDATE inbound_group_sessions SET uploaded = true WHERE client_id = :client_id AND room_id = :room_id AND session_id = :session_id;
|
||||||
storeUserDeviceKeysInfo: INSERT OR REPLACE INTO user_device_keys (client_id, user_id, outdated) VALUES (:client_id, :user_id, :outdated);
|
storeUserDeviceKeysInfo: INSERT OR REPLACE INTO user_device_keys (client_id, user_id, outdated) VALUES (:client_id, :user_id, :outdated);
|
||||||
setVerifiedUserDeviceKey: UPDATE user_device_keys_key SET verified = :verified WHERE client_id = :client_id AND user_id = :user_id AND device_id = :device_id;
|
setVerifiedUserDeviceKey: UPDATE user_device_keys_key SET verified = :verified WHERE client_id = :client_id AND user_id = :user_id AND device_id = :device_id;
|
||||||
setBlockedUserDeviceKey: UPDATE user_device_keys_key SET blocked = :blocked WHERE client_id = :client_id AND user_id = :user_id AND device_id = :device_id;
|
setBlockedUserDeviceKey: UPDATE user_device_keys_key SET blocked = :blocked WHERE client_id = :client_id AND user_id = :user_id AND device_id = :device_id;
|
||||||
|
|
|
@ -1,3 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Famedly Matrix SDK
|
||||||
|
* Copyright (C) 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:ansicolor/ansicolor.dart';
|
import 'package:ansicolor/ansicolor.dart';
|
||||||
|
|
||||||
abstract class Logs {
|
abstract class Logs {
|
||||||
|
|
30
lib/src/utils/run_in_background.dart
Normal file
30
lib/src/utils/run_in_background.dart
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Famedly Matrix SDK
|
||||||
|
* Copyright (C) 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:isolate/isolate.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
Future<T> runInBackground<T, U>(
|
||||||
|
FutureOr<T> Function(U arg) function, U arg) async {
|
||||||
|
final isolate = await IsolateRunner.spawn();
|
||||||
|
try {
|
||||||
|
return await isolate.run(function, arg);
|
||||||
|
} finally {
|
||||||
|
await isolate.close();
|
||||||
|
}
|
||||||
|
}
|
|
@ -281,6 +281,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.4"
|
version: "0.3.4"
|
||||||
|
isolate:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: isolate
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -609,7 +616,7 @@ packages:
|
||||||
name: test_coverage
|
name: test_coverage
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.1"
|
version: "0.4.3"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -22,10 +22,11 @@ dependencies:
|
||||||
olm: ^1.2.1
|
olm: ^1.2.1
|
||||||
matrix_file_e2ee: ^1.0.4
|
matrix_file_e2ee: ^1.0.4
|
||||||
ansicolor: ^1.0.2
|
ansicolor: ^1.0.2
|
||||||
|
isolate: ^2.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
test: ^1.0.0
|
test: ^1.0.0
|
||||||
test_coverage: ^0.4.1
|
test_coverage: ^0.4.3
|
||||||
moor_generator: ^3.0.0
|
moor_generator: ^3.0.0
|
||||||
build_runner: ^1.5.2
|
build_runner: ^1.5.2
|
||||||
pedantic: ^1.9.0
|
pedantic: ^1.9.0
|
||||||
|
|
4
test.sh
4
test.sh
|
@ -1,6 +1,6 @@
|
||||||
#!/bin/sh -e
|
#!/bin/sh -e
|
||||||
pub run test -p vm
|
# pub run test -p vm
|
||||||
pub run test_coverage
|
pub run test_coverage --print-test-output
|
||||||
pub global activate remove_from_coverage
|
pub global activate remove_from_coverage
|
||||||
pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$'
|
pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$'
|
||||||
genhtml -o coverage coverage/lcov.info || true
|
genhtml -o coverage coverage/lcov.info || true
|
||||||
|
|
|
@ -207,7 +207,7 @@ void main() {
|
||||||
|
|
||||||
test('ask SSSS start', () async {
|
test('ask SSSS start', () async {
|
||||||
client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(true);
|
client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(true);
|
||||||
await client1.database.clearSSSSCache(client1.id);
|
await client1.encryption.ssss.clearCache();
|
||||||
final req1 =
|
final req1 =
|
||||||
await client1.userDeviceKeys[client2.userID].startVerification();
|
await client1.userDeviceKeys[client2.userID].startVerification();
|
||||||
expect(req1.state, KeyVerificationState.askSSSS);
|
expect(req1.state, KeyVerificationState.askSSSS);
|
||||||
|
@ -288,7 +288,7 @@ void main() {
|
||||||
|
|
||||||
// alright, they match
|
// alright, they match
|
||||||
client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(true);
|
client1.userDeviceKeys[client1.userID].masterKey.setDirectVerified(true);
|
||||||
await client1.database.clearSSSSCache(client1.id);
|
await client1.encryption.ssss.clearCache();
|
||||||
|
|
||||||
// send mac
|
// send mac
|
||||||
FakeMatrixApi.calledEndpoints.clear();
|
FakeMatrixApi.calledEndpoints.clear();
|
||||||
|
@ -312,7 +312,7 @@ void main() {
|
||||||
|
|
||||||
client1.encryption.ssss = MockSSSS(client1.encryption);
|
client1.encryption.ssss = MockSSSS(client1.encryption);
|
||||||
(client1.encryption.ssss as MockSSSS).requestedSecrets = false;
|
(client1.encryption.ssss as MockSSSS).requestedSecrets = false;
|
||||||
await client1.database.clearSSSSCache(client1.id);
|
await client1.encryption.ssss.clearCache();
|
||||||
await req1.maybeRequestSSSSSecrets();
|
await req1.maybeRequestSSSSSecrets();
|
||||||
await Future.delayed(Duration(milliseconds: 10));
|
await Future.delayed(Duration(milliseconds: 10));
|
||||||
expect((client1.encryption.ssss as MockSSSS).requestedSecrets, true);
|
expect((client1.encryption.ssss as MockSSSS).requestedSecrets, true);
|
||||||
|
|
|
@ -16,12 +16,16 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:famedlysdk/famedlysdk.dart';
|
import 'package:famedlysdk/famedlysdk.dart';
|
||||||
import 'package:famedlysdk/src/utils/logs.dart';
|
import 'package:famedlysdk/src/utils/logs.dart';
|
||||||
|
import 'package:famedlysdk/matrix_api.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
import 'package:olm/olm.dart' as olm;
|
import 'package:olm/olm.dart' as olm;
|
||||||
|
|
||||||
import '../fake_client.dart';
|
import '../fake_client.dart';
|
||||||
|
import '../fake_matrix_api.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('Online Key Backup', () {
|
group('Online Key Backup', () {
|
||||||
|
@ -67,6 +71,49 @@ void main() {
|
||||||
true);
|
true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('upload key', () async {
|
||||||
|
final session = olm.OutboundGroupSession();
|
||||||
|
session.create();
|
||||||
|
final inbound = olm.InboundGroupSession();
|
||||||
|
inbound.create(session.session_key());
|
||||||
|
final senderKey = client.identityKey;
|
||||||
|
final roomId = '!someroom:example.org';
|
||||||
|
final sessionId = inbound.session_id();
|
||||||
|
// set a payload...
|
||||||
|
var sessionPayload = <String, dynamic>{
|
||||||
|
'algorithm': 'm.megolm.v1.aes-sha2',
|
||||||
|
'room_id': roomId,
|
||||||
|
'forwarding_curve25519_key_chain': [client.identityKey],
|
||||||
|
'session_id': sessionId,
|
||||||
|
'session_key': inbound.export_session(1),
|
||||||
|
'sender_key': senderKey,
|
||||||
|
'sender_claimed_ed25519_key': client.fingerprintKey,
|
||||||
|
};
|
||||||
|
FakeMatrixApi.calledEndpoints.clear();
|
||||||
|
client.encryption.keyManager.setInboundGroupSession(
|
||||||
|
roomId, sessionId, senderKey, sessionPayload,
|
||||||
|
forwarded: true);
|
||||||
|
var dbSessions =
|
||||||
|
await client.database.getInboundGroupSessionsToUpload().get();
|
||||||
|
expect(dbSessions.isNotEmpty, true);
|
||||||
|
await client.encryption.keyManager.backgroundTasks();
|
||||||
|
final payload = FakeMatrixApi
|
||||||
|
.calledEndpoints['/client/unstable/room_keys/keys?version=5'].first;
|
||||||
|
dbSessions =
|
||||||
|
await client.database.getInboundGroupSessionsToUpload().get();
|
||||||
|
expect(dbSessions.isEmpty, true);
|
||||||
|
|
||||||
|
final onlineKeys = RoomKeys.fromJson(json.decode(payload));
|
||||||
|
client.encryption.keyManager.clearInboundGroupSessions();
|
||||||
|
var ret = client.encryption.keyManager
|
||||||
|
.getInboundGroupSession(roomId, sessionId, senderKey);
|
||||||
|
expect(ret, null);
|
||||||
|
await client.encryption.keyManager.loadFromResponse(onlineKeys);
|
||||||
|
ret = client.encryption.keyManager
|
||||||
|
.getInboundGroupSession(roomId, sessionId, senderKey);
|
||||||
|
expect(ret != null, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('dispose client', () async {
|
test('dispose client', () async {
|
||||||
await client.dispose(closeDatabase: true);
|
await client.dispose(closeDatabase: true);
|
||||||
});
|
});
|
||||||
|
|
|
@ -248,7 +248,7 @@ void main() {
|
||||||
client.encryption.ssss.open('m.cross_signing.self_signing');
|
client.encryption.ssss.open('m.cross_signing.self_signing');
|
||||||
handle.unlock(recoveryKey: SSSS_KEY);
|
handle.unlock(recoveryKey: SSSS_KEY);
|
||||||
|
|
||||||
await client.database.clearSSSSCache(client.id);
|
await client.encryption.ssss.clearCache();
|
||||||
client.encryption.ssss.pendingShareRequests.clear();
|
client.encryption.ssss.pendingShareRequests.clear();
|
||||||
await client.encryption.ssss.request('best animal', [key]);
|
await client.encryption.ssss.request('best animal', [key]);
|
||||||
var event = ToDeviceEvent(
|
var event = ToDeviceEvent(
|
||||||
|
@ -272,7 +272,7 @@ void main() {
|
||||||
'm.megolm_backup.v1'
|
'm.megolm_backup.v1'
|
||||||
]) {
|
]) {
|
||||||
final secret = await handle.getStored(type);
|
final secret = await handle.getStored(type);
|
||||||
await client.database.clearSSSSCache(client.id);
|
await client.encryption.ssss.clearCache();
|
||||||
client.encryption.ssss.pendingShareRequests.clear();
|
client.encryption.ssss.pendingShareRequests.clear();
|
||||||
await client.encryption.ssss.request(type, [key]);
|
await client.encryption.ssss.request(type, [key]);
|
||||||
event = ToDeviceEvent(
|
event = ToDeviceEvent(
|
||||||
|
@ -294,7 +294,7 @@ void main() {
|
||||||
// test different fail scenarios
|
// test different fail scenarios
|
||||||
|
|
||||||
// not encrypted
|
// not encrypted
|
||||||
await client.database.clearSSSSCache(client.id);
|
await client.encryption.ssss.clearCache();
|
||||||
client.encryption.ssss.pendingShareRequests.clear();
|
client.encryption.ssss.pendingShareRequests.clear();
|
||||||
await client.encryption.ssss.request('best animal', [key]);
|
await client.encryption.ssss.request('best animal', [key]);
|
||||||
event = ToDeviceEvent(
|
event = ToDeviceEvent(
|
||||||
|
@ -309,7 +309,7 @@ void main() {
|
||||||
expect(await client.encryption.ssss.getCached('best animal'), null);
|
expect(await client.encryption.ssss.getCached('best animal'), null);
|
||||||
|
|
||||||
// unknown request id
|
// unknown request id
|
||||||
await client.database.clearSSSSCache(client.id);
|
await client.encryption.ssss.clearCache();
|
||||||
client.encryption.ssss.pendingShareRequests.clear();
|
client.encryption.ssss.pendingShareRequests.clear();
|
||||||
await client.encryption.ssss.request('best animal', [key]);
|
await client.encryption.ssss.request('best animal', [key]);
|
||||||
event = ToDeviceEvent(
|
event = ToDeviceEvent(
|
||||||
|
@ -327,7 +327,7 @@ void main() {
|
||||||
expect(await client.encryption.ssss.getCached('best animal'), null);
|
expect(await client.encryption.ssss.getCached('best animal'), null);
|
||||||
|
|
||||||
// not from a device we sent the request to
|
// not from a device we sent the request to
|
||||||
await client.database.clearSSSSCache(client.id);
|
await client.encryption.ssss.clearCache();
|
||||||
client.encryption.ssss.pendingShareRequests.clear();
|
client.encryption.ssss.pendingShareRequests.clear();
|
||||||
await client.encryption.ssss.request('best animal', [key]);
|
await client.encryption.ssss.request('best animal', [key]);
|
||||||
event = ToDeviceEvent(
|
event = ToDeviceEvent(
|
||||||
|
@ -345,7 +345,7 @@ void main() {
|
||||||
expect(await client.encryption.ssss.getCached('best animal'), null);
|
expect(await client.encryption.ssss.getCached('best animal'), null);
|
||||||
|
|
||||||
// secret not a string
|
// secret not a string
|
||||||
await client.database.clearSSSSCache(client.id);
|
await client.encryption.ssss.clearCache();
|
||||||
client.encryption.ssss.pendingShareRequests.clear();
|
client.encryption.ssss.pendingShareRequests.clear();
|
||||||
await client.encryption.ssss.request('best animal', [key]);
|
await client.encryption.ssss.request('best animal', [key]);
|
||||||
event = ToDeviceEvent(
|
event = ToDeviceEvent(
|
||||||
|
@ -363,7 +363,7 @@ void main() {
|
||||||
expect(await client.encryption.ssss.getCached('best animal'), null);
|
expect(await client.encryption.ssss.getCached('best animal'), null);
|
||||||
|
|
||||||
// validator doesn't check out
|
// validator doesn't check out
|
||||||
await client.database.clearSSSSCache(client.id);
|
await client.encryption.ssss.clearCache();
|
||||||
client.encryption.ssss.pendingShareRequests.clear();
|
client.encryption.ssss.pendingShareRequests.clear();
|
||||||
await client.encryption.ssss.request('m.megolm_backup.v1', [key]);
|
await client.encryption.ssss.request('m.megolm_backup.v1', [key]);
|
||||||
event = ToDeviceEvent(
|
event = ToDeviceEvent(
|
||||||
|
@ -386,7 +386,7 @@ void main() {
|
||||||
final key =
|
final key =
|
||||||
client.userDeviceKeys[client.userID].deviceKeys['OTHERDEVICE'];
|
client.userDeviceKeys[client.userID].deviceKeys['OTHERDEVICE'];
|
||||||
key.setDirectVerified(true);
|
key.setDirectVerified(true);
|
||||||
await client.database.clearSSSSCache(client.id);
|
await client.encryption.ssss.clearCache();
|
||||||
client.encryption.ssss.pendingShareRequests.clear();
|
client.encryption.ssss.pendingShareRequests.clear();
|
||||||
await client.encryption.ssss.maybeRequestAll([key]);
|
await client.encryption.ssss.maybeRequestAll([key]);
|
||||||
expect(client.encryption.ssss.pendingShareRequests.length, 3);
|
expect(client.encryption.ssss.pendingShareRequests.length, 3);
|
||||||
|
|
|
@ -132,6 +132,8 @@ void main() {
|
||||||
await client.checkServer('https://fakeserver.notexisting');
|
await client.checkServer('https://fakeserver.notexisting');
|
||||||
expect(user1.canChangePowerLevel, false);
|
expect(user1.canChangePowerLevel, false);
|
||||||
});
|
});
|
||||||
client.dispose();
|
test('dispose client', () async {
|
||||||
|
await client.dispose();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue