mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-03-03 10:36:47 +00:00
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
946 lines
28 KiB
Dart
946 lines
28 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'package:clock/clock.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
|
|
import 'package:cryptography_plus/cryptography_plus.dart';
|
|
import 'package:drift/drift.dart' show Value;
|
|
import 'package:fixnum/fixnum.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hashlib/random.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
|
|
// ignore: implementation_imports
|
|
import 'package:libsignal_protocol_dart/src/ecc/ed25519.dart';
|
|
import 'package:twonly/globals.dart';
|
|
import 'package:twonly/src/database/tables/groups.table.dart';
|
|
import 'package:twonly/src/database/twonly.db.dart';
|
|
import 'package:twonly/src/model/protobuf/api/http/http_requests.pb.dart';
|
|
import 'package:twonly/src/model/protobuf/client/generated/groups.pb.dart';
|
|
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart';
|
|
import 'package:twonly/src/services/api/messages.dart';
|
|
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
|
|
import 'package:twonly/src/services/signal/session.signal.dart';
|
|
import 'package:twonly/src/utils/log.dart';
|
|
import 'package:twonly/src/utils/misc.dart';
|
|
|
|
String getGroupStateUrl() {
|
|
return 'http${apiService.apiSecure}://${apiService.apiHost}/api/group/state';
|
|
}
|
|
|
|
String getGroupChallengeUrl() {
|
|
return 'http${apiService.apiSecure}://${apiService.apiHost}/api/group/challenge';
|
|
}
|
|
|
|
Future<bool> createNewGroup(String groupName, List<Contact> members) async {
|
|
// First: Upload new State to the server.....
|
|
// if (groupName) return;
|
|
|
|
final groupId = uuid.v4();
|
|
|
|
final memberIds = members.map((x) => Int64(x.userId)).toList();
|
|
|
|
final groupState = EncryptedGroupState(
|
|
memberIds: [Int64(gUser.userId)] + memberIds,
|
|
adminIds: [Int64(gUser.userId)],
|
|
groupName: groupName,
|
|
deleteMessagesAfterMilliseconds:
|
|
Int64(defaultDeleteMessagesAfterMilliseconds),
|
|
padding: List<int>.generate(Random().nextInt(80), (_) => 0),
|
|
);
|
|
|
|
final stateEncryptionKey = getRandomUint8List(32);
|
|
final chacha20 = FlutterChacha20.poly1305Aead();
|
|
final encryptionNonce = chacha20.newNonce();
|
|
|
|
final secretBox = await chacha20.encrypt(
|
|
groupState.writeToBuffer(),
|
|
secretKey: SecretKey(stateEncryptionKey),
|
|
nonce: encryptionNonce,
|
|
);
|
|
|
|
final encryptedGroupState = EncryptedGroupStateEnvelop(
|
|
nonce: encryptionNonce,
|
|
encryptedGroupState: secretBox.cipherText,
|
|
mac: secretBox.mac.bytes,
|
|
);
|
|
|
|
final myGroupKey = generateIdentityKeyPair();
|
|
|
|
{
|
|
// Upload the group state, if this fails, the group can not be created.
|
|
|
|
final newGroupState = NewGroupState(
|
|
groupId: groupId,
|
|
versionId: Int64(1),
|
|
encryptedGroupState: encryptedGroupState.writeToBuffer(),
|
|
publicKey: myGroupKey.getPublicKey().serialize(),
|
|
);
|
|
|
|
final response = await http
|
|
.post(
|
|
Uri.parse(getGroupStateUrl()),
|
|
body: newGroupState.writeToBuffer(),
|
|
)
|
|
.timeout(const Duration(seconds: 10));
|
|
|
|
if (response.statusCode != 200) {
|
|
Log.error(
|
|
'Could not upload group state. Got status code ${response.statusCode} from server.',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
final group = await twonlyDB.groupsDao.createNewGroup(
|
|
GroupsCompanion(
|
|
groupId: Value(groupId),
|
|
groupName: Value(groupName),
|
|
isGroupAdmin: const Value(true),
|
|
stateEncryptionKey: Value(stateEncryptionKey),
|
|
stateVersionId: const Value(1),
|
|
myGroupPrivateKey: Value(myGroupKey.serialize()),
|
|
joinedGroup: const Value(true),
|
|
),
|
|
);
|
|
|
|
if (group == null) {
|
|
Log.error('Could not insert group into database.');
|
|
return false;
|
|
}
|
|
|
|
Log.info('Created new group: ${group.groupId}');
|
|
|
|
for (final member in members) {
|
|
await twonlyDB.groupsDao.insertOrUpdateGroupMember(
|
|
GroupMembersCompanion(
|
|
groupId: Value(group.groupId),
|
|
contactId: Value(member.userId),
|
|
memberState: const Value(MemberState.normal),
|
|
),
|
|
);
|
|
}
|
|
|
|
await twonlyDB.groupsDao.insertGroupAction(
|
|
GroupHistoriesCompanion(
|
|
groupId: Value(groupId),
|
|
type: const Value(GroupActionType.createdGroup),
|
|
),
|
|
);
|
|
|
|
// Notify members about the new group :)
|
|
|
|
await sendCipherTextToGroup(
|
|
group.groupId,
|
|
EncryptedContent(
|
|
groupCreate: EncryptedContent_GroupCreate(
|
|
stateKey: stateEncryptionKey,
|
|
groupPublicKey: myGroupKey.getPublicKey().serialize(),
|
|
),
|
|
),
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
Future<void> fetchGroupStatesForUnjoinedGroups() async {
|
|
final groups = await twonlyDB.groupsDao.getAllNotJoinedGroups();
|
|
|
|
for (final group in groups) {
|
|
await fetchGroupState(group);
|
|
}
|
|
}
|
|
|
|
Future<void> fetchMissingGroupPublicKey() async {
|
|
final members = await twonlyDB.groupsDao.getAllGroupMemberWithoutPublicKey();
|
|
|
|
for (final member in members) {
|
|
if (member.lastMessage == null) continue;
|
|
// only request if the users has send a message in the last two days.
|
|
if (member.lastMessage!
|
|
.isAfter(clock.now().subtract(const Duration(days: 2)))) {
|
|
await sendCipherText(
|
|
member.contactId,
|
|
EncryptedContent(
|
|
groupId: member.groupId,
|
|
resendGroupPublicKey: EncryptedContent_ResendGroupPublicKey(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<List<int>?> _decryptEnvelop(
|
|
Group group,
|
|
List<int> encryptedGroupState,
|
|
) async {
|
|
try {
|
|
final envelope = EncryptedGroupStateEnvelop.fromBuffer(
|
|
encryptedGroupState,
|
|
);
|
|
final chacha20 = FlutterChacha20.poly1305Aead();
|
|
|
|
final secretBox = SecretBox(
|
|
envelope.encryptedGroupState,
|
|
nonce: envelope.nonce,
|
|
mac: Mac(envelope.mac),
|
|
);
|
|
|
|
final encryptedGroupStateRaw = await chacha20.decrypt(
|
|
secretBox,
|
|
secretKey: SecretKey(group.stateEncryptionKey!),
|
|
);
|
|
|
|
return encryptedGroupStateRaw;
|
|
} catch (e) {
|
|
Log.error(e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async {
|
|
try {
|
|
var isSuccess = true;
|
|
|
|
final response = await http
|
|
.get(
|
|
Uri.parse('${getGroupStateUrl()}/${group.groupId}'),
|
|
)
|
|
.timeout(const Duration(seconds: 10));
|
|
|
|
if (response.statusCode != 200) {
|
|
if (response.statusCode == 404) {
|
|
// group does not exists any more.
|
|
await twonlyDB.groupsDao.updateGroup(
|
|
group.groupId,
|
|
const GroupsCompanion(
|
|
leftGroup: Value(true),
|
|
),
|
|
);
|
|
}
|
|
Log.error(
|
|
'Could not load group state. Got status code ${response.statusCode} from server.',
|
|
);
|
|
return null;
|
|
}
|
|
|
|
final groupStateServer = GroupState.fromBuffer(response.bodyBytes);
|
|
|
|
final encryptedStateRaw =
|
|
await _decryptEnvelop(group, groupStateServer.encryptedGroupState);
|
|
if (encryptedStateRaw == null) return null;
|
|
|
|
final encryptedGroupState =
|
|
EncryptedGroupState.fromBuffer(encryptedStateRaw);
|
|
|
|
if (group.stateVersionId >= groupStateServer.versionId.toInt()) {
|
|
Log.info(
|
|
'Group ${group.groupId} has already newest group state from the server!',
|
|
);
|
|
}
|
|
|
|
final memberIds = List<Int64>.from(encryptedGroupState.memberIds);
|
|
final adminIds = List<Int64>.from(encryptedGroupState.adminIds);
|
|
|
|
for (final appendedState in groupStateServer.appendedGroupStates) {
|
|
final identityKey = IdentityKey.fromBytes(
|
|
Uint8List.fromList(appendedState.appendTBS.publicKey),
|
|
0,
|
|
);
|
|
|
|
final valid = Curve.verifySignature(
|
|
identityKey.publicKey,
|
|
appendedState.appendTBS.writeToBuffer(),
|
|
Uint8List.fromList(appendedState.signature),
|
|
);
|
|
|
|
if (!valid) {
|
|
Log.error('Invalid signature for the appendedState');
|
|
continue;
|
|
}
|
|
|
|
final encryptedStateRaw = await _decryptEnvelop(
|
|
group,
|
|
appendedState.appendTBS.encryptedGroupStateAppend,
|
|
);
|
|
if (encryptedStateRaw == null) continue;
|
|
|
|
final appended =
|
|
EncryptedAppendedGroupState.fromBuffer(encryptedStateRaw);
|
|
if (appended.type == EncryptedAppendedGroupState_Type.LEFT_GROUP) {
|
|
final keyPair =
|
|
IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!);
|
|
|
|
final appendedPubKey = appendedState.appendTBS.publicKey;
|
|
final myPubKey = keyPair.getPublicKey().serialize().toList();
|
|
|
|
if (listEquals(appendedPubKey, myPubKey)) {
|
|
adminIds.remove(Int64(gUser.userId));
|
|
memberIds
|
|
.remove(Int64(gUser.userId)); // -> Will remove the user later...
|
|
} else {
|
|
Log.info('A non admin left the group!!!');
|
|
|
|
final member = await twonlyDB.groupsDao
|
|
.getGroupMemberByPublicKey(Uint8List.fromList(appendedPubKey));
|
|
if (member == null) {
|
|
Log.error('Member is already not in this group...');
|
|
continue;
|
|
}
|
|
adminIds.remove(Int64(member.contactId));
|
|
memberIds.remove(Int64(member.contactId));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!memberIds.contains(Int64(gUser.userId))) {
|
|
// OH no, I am no longer a member of this group...
|
|
// Return from the group...
|
|
await twonlyDB.groupsDao.updateGroup(
|
|
group.groupId,
|
|
const GroupsCompanion(
|
|
leftGroup: Value(true),
|
|
),
|
|
);
|
|
return (groupStateServer.versionId.toInt(), encryptedGroupState);
|
|
}
|
|
|
|
final isGroupAdmin =
|
|
adminIds.firstWhereOrNull((t) => t.toInt() == gUser.userId) != null;
|
|
|
|
if (!listEquals(memberIds, encryptedGroupState.memberIds)) {
|
|
if (isGroupAdmin) {
|
|
try {
|
|
// this removes the appended_group_state from the server and merges the changes into the main group state
|
|
final newState = EncryptedGroupState(
|
|
groupName: encryptedGroupState.groupName,
|
|
deleteMessagesAfterMilliseconds:
|
|
encryptedGroupState.deleteMessagesAfterMilliseconds,
|
|
memberIds: memberIds,
|
|
adminIds: adminIds,
|
|
padding: List<int>.generate(Random().nextInt(80), (_) => 0),
|
|
);
|
|
// send new state to the server
|
|
if (!await _updateGroupState(
|
|
group,
|
|
newState,
|
|
versionId: groupStateServer.versionId.toInt() + 1,
|
|
)) {
|
|
// could not update the group state...
|
|
Log.error('Update the state to remove the appended state...');
|
|
return null;
|
|
}
|
|
// the state is now updated and the appended_group_state should be removed on the server, so just call this
|
|
// function again, to sync the local database
|
|
return fetchGroupState(group);
|
|
} catch (e) {
|
|
Log.error(e);
|
|
return null;
|
|
}
|
|
}
|
|
// in case this is not an admin, just work with the new memberIds and adminIds...
|
|
}
|
|
|
|
await twonlyDB.groupsDao.updateGroup(
|
|
group.groupId,
|
|
GroupsCompanion(
|
|
groupName: Value(encryptedGroupState.groupName),
|
|
deleteMessagesAfterMilliseconds: Value(
|
|
encryptedGroupState.deleteMessagesAfterMilliseconds.toInt(),
|
|
),
|
|
isGroupAdmin: Value(isGroupAdmin),
|
|
),
|
|
);
|
|
|
|
var currentGroupMembers =
|
|
await twonlyDB.groupsDao.getGroupNonLeftMembers(group.groupId);
|
|
|
|
// First find and insert NEW members
|
|
for (final memberId in memberIds) {
|
|
if (memberId == Int64(gUser.userId)) {
|
|
continue;
|
|
}
|
|
if (currentGroupMembers.any((t) => t.contactId == memberId.toInt())) {
|
|
// User is already in the database
|
|
continue;
|
|
}
|
|
Log.info('New member in the GROUP state: $memberId');
|
|
|
|
var inContacts = true;
|
|
|
|
if (await twonlyDB.contactsDao.getContactById(memberId.toInt()) == null) {
|
|
// User is not yet in the contacts, add him in the hidden. So he is not in the contact list / needs to be
|
|
// requested separately.
|
|
if (!await addNewHiddenContact(memberId.toInt())) {
|
|
Log.error('Could not request member ID will retry later.');
|
|
isSuccess = false;
|
|
inContacts = false;
|
|
}
|
|
}
|
|
if (inContacts) {
|
|
// User is already a contact, so just add him to the group members list
|
|
await twonlyDB.groupsDao.insertOrUpdateGroupMember(
|
|
GroupMembersCompanion(
|
|
groupId: Value(group.groupId),
|
|
contactId: Value(memberId.toInt()),
|
|
memberState: const Value(MemberState.normal),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Send the new user my public group key
|
|
if (group.myGroupPrivateKey != null) {
|
|
final keyPair =
|
|
IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!);
|
|
await sendCipherText(
|
|
memberId.toInt(),
|
|
EncryptedContent(
|
|
groupJoin: EncryptedContent_GroupJoin(
|
|
groupPublicKey: keyPair.getPublicKey().serialize(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// check if there is a member which is not in the server list...
|
|
|
|
// update the current members list
|
|
currentGroupMembers =
|
|
await twonlyDB.groupsDao.getGroupNonLeftMembers(group.groupId);
|
|
|
|
for (final member in currentGroupMembers) {
|
|
// Member is not any more in the members list
|
|
if (!encryptedGroupState.memberIds.contains(Int64(member.contactId))) {
|
|
await twonlyDB.groupsDao.removeMember(group.groupId, member.contactId);
|
|
continue;
|
|
}
|
|
|
|
MemberState? newMemberState;
|
|
|
|
if (adminIds.contains(Int64(member.contactId))) {
|
|
if (member.memberState == MemberState.normal) {
|
|
// user was promoted
|
|
newMemberState = MemberState.admin;
|
|
}
|
|
} else if (member.memberState == MemberState.admin) {
|
|
// user was demoted
|
|
newMemberState = MemberState.normal;
|
|
}
|
|
|
|
if (newMemberState != null) {
|
|
await twonlyDB.groupsDao.updateMember(
|
|
group.groupId,
|
|
member.contactId,
|
|
GroupMembersCompanion(
|
|
memberState: Value(newMemberState),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isSuccess) {
|
|
// in case not all members could be loaded from the server,
|
|
// this will ensure it will be tried again later
|
|
await twonlyDB.groupsDao.updateGroup(
|
|
group.groupId,
|
|
GroupsCompanion(
|
|
stateVersionId: Value(groupStateServer.versionId.toInt()),
|
|
joinedGroup: const Value(true),
|
|
),
|
|
);
|
|
}
|
|
return (groupStateServer.versionId.toInt(), encryptedGroupState);
|
|
} catch (e) {
|
|
Log.error(e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> addNewHiddenContact(int contactId) async {
|
|
final userData = await apiService.getUserById(contactId);
|
|
if (userData == null) {
|
|
Log.error('Could not load contact informations');
|
|
return false;
|
|
}
|
|
await twonlyDB.contactsDao.insertOnConflictUpdate(
|
|
ContactsCompanion(
|
|
username: Value(utf8.decode(userData.username)),
|
|
userId: Value(contactId),
|
|
deletedByUser:
|
|
const Value(true), // this will hide the contact in the contact list
|
|
),
|
|
);
|
|
await processSignalUserData(userData);
|
|
unawaited(setupNotificationWithUsers(forceContact: contactId));
|
|
return true;
|
|
}
|
|
|
|
Future<bool> _updateGroupState(
|
|
Group group,
|
|
EncryptedGroupState state, {
|
|
Uint8List? addAdmin,
|
|
Uint8List? removeAdmin,
|
|
int? versionId,
|
|
}) async {
|
|
final chacha20 = FlutterChacha20.poly1305Aead();
|
|
final encryptionNonce = chacha20.newNonce();
|
|
|
|
final secretBox = await chacha20.encrypt(
|
|
state.writeToBuffer(),
|
|
secretKey: SecretKey(group.stateEncryptionKey!),
|
|
nonce: encryptionNonce,
|
|
);
|
|
|
|
final encryptedGroupState = EncryptedGroupStateEnvelop(
|
|
nonce: encryptionNonce,
|
|
encryptedGroupState: secretBox.cipherText,
|
|
mac: secretBox.mac.bytes,
|
|
);
|
|
|
|
{
|
|
// Upload the group state, if this fails, the group can not be created.
|
|
|
|
final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!);
|
|
|
|
final nonce = await getNonce(keyPair.getPublicKey().serialize());
|
|
if (nonce == null) return false;
|
|
|
|
final updateTBS = UpdateGroupState_UpdateTBS(
|
|
versionId: Int64(versionId ?? group.stateVersionId + 1),
|
|
encryptedGroupState: encryptedGroupState.writeToBuffer(),
|
|
publicKey: keyPair.getPublicKey().serialize(),
|
|
nonce: nonce,
|
|
addAdmin: addAdmin,
|
|
removeAdmin: removeAdmin,
|
|
);
|
|
|
|
final random = getRandomUint8List(32);
|
|
final signature = sign(
|
|
keyPair.getPrivateKey().serialize(),
|
|
updateTBS.writeToBuffer(),
|
|
random,
|
|
);
|
|
|
|
final newGroupState = UpdateGroupState(
|
|
update: updateTBS,
|
|
signature: signature,
|
|
);
|
|
|
|
final response = await http
|
|
.patch(
|
|
Uri.parse(getGroupStateUrl()),
|
|
body: newGroupState.writeToBuffer(),
|
|
)
|
|
.timeout(const Duration(seconds: 10));
|
|
|
|
if (response.statusCode != 200) {
|
|
Log.error(
|
|
'Could not patch group state. Got status code ${response.statusCode} from server.',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Future<bool> manageAdminState(
|
|
Group group,
|
|
Uint8List groupPublicKey,
|
|
int contactId,
|
|
bool remove,
|
|
) async {
|
|
// ensure the latest state is used
|
|
final currentState = await fetchGroupState(group);
|
|
if (currentState == null) return false;
|
|
final (versionId, state) = currentState;
|
|
|
|
final userId = Int64(contactId);
|
|
|
|
Uint8List? addAdmin;
|
|
Uint8List? removeAdmin;
|
|
|
|
if (remove) {
|
|
if (state.adminIds.contains(userId)) {
|
|
state.adminIds.remove(userId);
|
|
removeAdmin = groupPublicKey;
|
|
} else {
|
|
Log.info('User was already removed as admin.');
|
|
return true;
|
|
}
|
|
} else {
|
|
if (!state.adminIds.contains(userId)) {
|
|
state.adminIds.add(userId);
|
|
addAdmin = groupPublicKey;
|
|
} else {
|
|
Log.info('User is already admin.');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (addAdmin == null && removeAdmin == null) {
|
|
Log.info('User does not have a group public key.');
|
|
return false;
|
|
}
|
|
|
|
// send new state to the server
|
|
if (!await _updateGroupState(
|
|
group,
|
|
state,
|
|
addAdmin: addAdmin,
|
|
removeAdmin: removeAdmin,
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
final groupActionType =
|
|
remove ? GroupActionType.demoteToMember : GroupActionType.promoteToAdmin;
|
|
|
|
await sendCipherTextToGroup(
|
|
group.groupId,
|
|
EncryptedContent(
|
|
groupUpdate: EncryptedContent_GroupUpdate(
|
|
groupActionType: groupActionType.name,
|
|
affectedContactId: Int64(contactId),
|
|
),
|
|
),
|
|
);
|
|
|
|
await twonlyDB.groupsDao.insertGroupAction(
|
|
GroupHistoriesCompanion(
|
|
groupId: Value(group.groupId),
|
|
type: Value(groupActionType),
|
|
affectedContactId: Value(contactId),
|
|
),
|
|
);
|
|
|
|
// Updates the memberState :)
|
|
return (await fetchGroupState(group)) != null;
|
|
}
|
|
|
|
Future<bool> updateGroupName(Group group, String groupName) async {
|
|
// ensure the latest state is used
|
|
final currentState = await fetchGroupState(group);
|
|
if (currentState == null) return false;
|
|
final (versionId, state) = currentState;
|
|
|
|
state.groupName = groupName;
|
|
|
|
// send new state to the server
|
|
if (!await _updateGroupState(group, state)) {
|
|
return false;
|
|
}
|
|
|
|
await sendCipherTextToGroup(
|
|
group.groupId,
|
|
EncryptedContent(
|
|
groupUpdate: EncryptedContent_GroupUpdate(
|
|
groupActionType: GroupActionType.updatedGroupName.name,
|
|
newGroupName: groupName,
|
|
),
|
|
),
|
|
);
|
|
|
|
await twonlyDB.groupsDao.insertGroupAction(
|
|
GroupHistoriesCompanion(
|
|
groupId: Value(group.groupId),
|
|
type: const Value(GroupActionType.updatedGroupName),
|
|
oldGroupName: Value(group.groupName),
|
|
newGroupName: Value(groupName),
|
|
),
|
|
);
|
|
|
|
// Updates the groupName :)
|
|
return (await fetchGroupState(group)) != null;
|
|
}
|
|
|
|
Future<bool> updateChatDeletionTime(
|
|
Group group,
|
|
int deleteMessagesAfterMilliseconds,
|
|
) async {
|
|
// ensure the latest state is used
|
|
final currentState = await fetchGroupState(group);
|
|
if (currentState == null) return false;
|
|
final (versionId, state) = currentState;
|
|
|
|
state.deleteMessagesAfterMilliseconds =
|
|
Int64(deleteMessagesAfterMilliseconds);
|
|
|
|
// send new state to the server
|
|
if (!await _updateGroupState(group, state)) {
|
|
return false;
|
|
}
|
|
|
|
await sendCipherTextToGroup(
|
|
group.groupId,
|
|
EncryptedContent(
|
|
groupUpdate: EncryptedContent_GroupUpdate(
|
|
groupActionType: GroupActionType.changeDisplayMaxTime.name,
|
|
newDeleteMessagesAfterMilliseconds: Int64(
|
|
deleteMessagesAfterMilliseconds,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
await twonlyDB.groupsDao.insertGroupAction(
|
|
GroupHistoriesCompanion(
|
|
groupId: Value(group.groupId),
|
|
type: const Value(GroupActionType.changeDisplayMaxTime),
|
|
newDeleteMessagesAfterMilliseconds:
|
|
Value(deleteMessagesAfterMilliseconds),
|
|
),
|
|
);
|
|
|
|
// Updates the groupName :)
|
|
return (await fetchGroupState(group)) != null;
|
|
}
|
|
|
|
Future<bool> addNewGroupMembers(
|
|
Group group,
|
|
List<int> newGroupMemberIds,
|
|
) async {
|
|
// ensure the latest state is used
|
|
final currentState = await fetchGroupState(group);
|
|
if (currentState == null) return false;
|
|
final (versionId, state) = currentState;
|
|
|
|
var memberIds = state.memberIds + newGroupMemberIds.map(Int64.new).toList();
|
|
memberIds = memberIds.toSet().toList();
|
|
|
|
final newState = EncryptedGroupState(
|
|
groupName: state.groupName,
|
|
deleteMessagesAfterMilliseconds: state.deleteMessagesAfterMilliseconds,
|
|
memberIds: memberIds,
|
|
adminIds: state.adminIds,
|
|
padding: List<int>.generate(Random().nextInt(80), (_) => 0),
|
|
);
|
|
|
|
// send new state to the server
|
|
if (!await _updateGroupState(group, newState)) {
|
|
return false;
|
|
}
|
|
|
|
final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!);
|
|
|
|
for (final newMember in newGroupMemberIds) {
|
|
await sendCipherTextToGroup(
|
|
group.groupId,
|
|
EncryptedContent(
|
|
groupUpdate: EncryptedContent_GroupUpdate(
|
|
groupActionType: GroupActionType.addMember.name,
|
|
affectedContactId: Int64(newMember),
|
|
),
|
|
),
|
|
);
|
|
|
|
await twonlyDB.groupsDao.insertGroupAction(
|
|
GroupHistoriesCompanion(
|
|
groupId: Value(group.groupId),
|
|
type: const Value(GroupActionType.addMember),
|
|
affectedContactId: Value(newMember),
|
|
),
|
|
);
|
|
|
|
await sendCipherText(
|
|
newMember,
|
|
EncryptedContent(
|
|
groupId: group.groupId,
|
|
groupCreate: EncryptedContent_GroupCreate(
|
|
stateKey: group.stateEncryptionKey,
|
|
groupPublicKey: keyPair.getPublicKey().serialize(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Updates the groupMembers table :)
|
|
return (await fetchGroupState(group)) != null;
|
|
}
|
|
|
|
Future<bool> removeMemberFromGroup(
|
|
Group group,
|
|
Uint8List groupPublicKey,
|
|
int removeContactId,
|
|
) async {
|
|
// ensure the latest state is used
|
|
final currentState = await fetchGroupState(group);
|
|
if (currentState == null) return false;
|
|
final (versionId, state) = currentState;
|
|
|
|
final contactId = Int64(removeContactId);
|
|
|
|
final membersIdSet = state.memberIds.toSet();
|
|
final adminIdSet = state.adminIds.toSet();
|
|
Uint8List? removeAdmin;
|
|
if (!membersIdSet.contains(contactId)) {
|
|
Log.info('User was already removed from the group!');
|
|
return true;
|
|
}
|
|
if (adminIdSet.contains(contactId)) {
|
|
// if (member.groupPublicKey == null) {
|
|
// // If the admin public key is not removed, that the user could potentially still update the group state. So only
|
|
// // allow the user removal, if this key is known. It is better the users can not remove the other user, then
|
|
// // the he can but the other user, could still update the group state.
|
|
// Log.error(
|
|
// 'Could not remove user. User is admin, but groupPublicKey is unknown.',
|
|
// );
|
|
// return false;
|
|
// }
|
|
removeAdmin = groupPublicKey;
|
|
}
|
|
|
|
membersIdSet.remove(contactId);
|
|
adminIdSet.remove(contactId);
|
|
|
|
final newState = EncryptedGroupState(
|
|
groupName: state.groupName,
|
|
deleteMessagesAfterMilliseconds: state.deleteMessagesAfterMilliseconds,
|
|
memberIds: membersIdSet.toList(),
|
|
adminIds: adminIdSet.toList(),
|
|
padding: List<int>.generate(Random().nextInt(80), (_) => 0),
|
|
);
|
|
|
|
// send new state to the server
|
|
if (!await _updateGroupState(group, newState, removeAdmin: removeAdmin)) {
|
|
return false;
|
|
}
|
|
|
|
await sendCipherTextToGroup(
|
|
group.groupId,
|
|
EncryptedContent(
|
|
groupUpdate: EncryptedContent_GroupUpdate(
|
|
groupActionType: GroupActionType.removedMember.name,
|
|
affectedContactId: Int64(removeContactId),
|
|
),
|
|
),
|
|
);
|
|
|
|
await twonlyDB.groupsDao.insertGroupAction(
|
|
GroupHistoriesCompanion(
|
|
groupId: Value(group.groupId),
|
|
type: const Value(GroupActionType.removedMember),
|
|
affectedContactId: Value(
|
|
removeContactId == gUser.userId ? null : removeContactId,
|
|
),
|
|
),
|
|
);
|
|
|
|
// Updates the groupMembers table :)
|
|
return (await fetchGroupState(group)) != null;
|
|
}
|
|
|
|
Future<Uint8List?> getNonce(Uint8List publicKey) async {
|
|
final publicKeyHex = uint8ListToHex(publicKey);
|
|
|
|
final responseNonce = await http
|
|
.get(
|
|
Uri.parse('${getGroupChallengeUrl()}/$publicKeyHex'),
|
|
)
|
|
.timeout(const Duration(seconds: 10));
|
|
|
|
if (responseNonce.statusCode != 200) {
|
|
Log.error(
|
|
'Could not load nonce. Got status code ${responseNonce.statusCode} from server.',
|
|
);
|
|
return null;
|
|
}
|
|
return responseNonce.bodyBytes;
|
|
}
|
|
|
|
Future<bool> leaveAsNonAdminFromGroup(Group group) async {
|
|
final currentState = await fetchGroupState(group);
|
|
if (currentState == null) {
|
|
Log.error('Could not load current state');
|
|
return false;
|
|
}
|
|
|
|
final (version, _) = currentState;
|
|
if (group.stateVersionId != version) {
|
|
Log.error('Version is not valid. Just retry.');
|
|
return false;
|
|
}
|
|
|
|
final chacha20 = FlutterChacha20.poly1305Aead();
|
|
final encryptionNonce = chacha20.newNonce();
|
|
|
|
final state = EncryptedAppendedGroupState(
|
|
type: EncryptedAppendedGroupState_Type.LEFT_GROUP,
|
|
);
|
|
|
|
final secretBox = await chacha20.encrypt(
|
|
state.writeToBuffer(),
|
|
secretKey: SecretKey(group.stateEncryptionKey!),
|
|
nonce: encryptionNonce,
|
|
);
|
|
|
|
final encryptedGroupStateAppend = EncryptedGroupStateEnvelop(
|
|
nonce: encryptionNonce,
|
|
encryptedGroupState: secretBox.cipherText,
|
|
mac: secretBox.mac.bytes,
|
|
);
|
|
|
|
{
|
|
// Upload the group state, if this fails, the group can not be created.
|
|
|
|
final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!);
|
|
|
|
final nonce = await getNonce(keyPair.getPublicKey().serialize());
|
|
if (nonce == null) return false;
|
|
|
|
final appendTBS = AppendGroupState_AppendTBS(
|
|
publicKey: keyPair.getPublicKey().serialize(),
|
|
encryptedGroupStateAppend: encryptedGroupStateAppend.writeToBuffer(),
|
|
groupId: group.groupId,
|
|
nonce: nonce,
|
|
);
|
|
|
|
final random = getRandomUint8List(32);
|
|
final signature = sign(
|
|
keyPair.getPrivateKey().serialize(),
|
|
appendTBS.writeToBuffer(),
|
|
random,
|
|
);
|
|
|
|
final newGroupState = AppendGroupState(
|
|
versionId: Int64(group.stateVersionId + 1),
|
|
appendTBS: appendTBS,
|
|
signature: signature,
|
|
);
|
|
|
|
final response = await http
|
|
.post(
|
|
Uri.parse('${getGroupStateUrl()}/append'),
|
|
body: newGroupState.writeToBuffer(),
|
|
)
|
|
.timeout(const Duration(seconds: 10));
|
|
|
|
if (response.statusCode != 200) {
|
|
Log.error(
|
|
'Could not patch group state. Got status code ${response.statusCode} from server.',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
const groupActionType = GroupActionType.leftGroup;
|
|
await sendCipherTextToGroup(
|
|
group.groupId,
|
|
EncryptedContent(
|
|
groupUpdate: EncryptedContent_GroupUpdate(
|
|
groupActionType: groupActionType.name,
|
|
affectedContactId: Int64(gUser.userId),
|
|
),
|
|
),
|
|
);
|
|
|
|
await twonlyDB.groupsDao.insertGroupAction(
|
|
GroupHistoriesCompanion(
|
|
groupId: Value(group.groupId),
|
|
type: const Value(groupActionType),
|
|
),
|
|
);
|
|
|
|
// Updates the table :)
|
|
return (await fetchGroupState(group)) != null;
|
|
}
|