]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
Approve join requests instead of just adding the member
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / GroupV2Helper.java
1 package org.asamk.signal.manager.helper;
2
3 import com.google.protobuf.ByteString;
4 import com.google.protobuf.InvalidProtocolBufferException;
5
6 import org.asamk.signal.manager.SignalDependencies;
7 import org.asamk.signal.manager.api.Pair;
8 import org.asamk.signal.manager.groups.GroupLinkPassword;
9 import org.asamk.signal.manager.groups.GroupLinkState;
10 import org.asamk.signal.manager.groups.GroupPermission;
11 import org.asamk.signal.manager.groups.GroupUtils;
12 import org.asamk.signal.manager.groups.NotAGroupMemberException;
13 import org.asamk.signal.manager.storage.groups.GroupInfoV2;
14 import org.asamk.signal.manager.storage.recipients.RecipientId;
15 import org.asamk.signal.manager.util.IOUtils;
16 import org.asamk.signal.manager.util.Utils;
17 import org.signal.libsignal.zkgroup.InvalidInputException;
18 import org.signal.libsignal.zkgroup.VerificationFailedException;
19 import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
20 import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
21 import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
22 import org.signal.libsignal.zkgroup.groups.UuidCiphertext;
23 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
24 import org.signal.storageservice.protos.groups.AccessControl;
25 import org.signal.storageservice.protos.groups.GroupChange;
26 import org.signal.storageservice.protos.groups.Member;
27 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
28 import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
29 import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
30 import org.signal.storageservice.protos.groups.local.DecryptedMember;
31 import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
32 import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
33 import org.slf4j.Logger;
34 import org.slf4j.LoggerFactory;
35 import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
36 import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
37 import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage;
38 import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
39 import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
40 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
41 import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
42 import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
43 import org.whispersystems.signalservice.api.push.ACI;
44 import org.whispersystems.signalservice.api.push.PNI;
45 import org.whispersystems.signalservice.api.push.ServiceId;
46 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
47 import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
48 import org.whispersystems.signalservice.api.util.UuidUtil;
49
50 import java.io.File;
51 import java.io.FileInputStream;
52 import java.io.IOException;
53 import java.io.InputStream;
54 import java.util.ArrayList;
55 import java.util.Arrays;
56 import java.util.HashMap;
57 import java.util.List;
58 import java.util.Optional;
59 import java.util.Set;
60 import java.util.UUID;
61 import java.util.concurrent.TimeUnit;
62 import java.util.function.Function;
63 import java.util.stream.Collectors;
64 import java.util.stream.Stream;
65
66 class GroupV2Helper {
67
68 private final static Logger logger = LoggerFactory.getLogger(GroupV2Helper.class);
69
70 private final SignalDependencies dependencies;
71 private final Context context;
72
73 private HashMap<Long, AuthCredentialWithPniResponse> groupApiCredentials;
74
75 GroupV2Helper(final Context context) {
76 this.dependencies = context.getDependencies();
77 this.context = context;
78 }
79
80 DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) throws NotAGroupMemberException {
81 try {
82 final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
83 return dependencies.getGroupsV2Api().getGroup(groupSecretParams, groupsV2AuthorizationString);
84 } catch (NonSuccessfulResponseCodeException e) {
85 if (e.getCode() == 403) {
86 throw new NotAGroupMemberException(GroupUtils.getGroupIdV2(groupSecretParams), null);
87 }
88 logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
89 return null;
90 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
91 logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
92 return null;
93 }
94 }
95
96 DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
97 GroupMasterKey groupMasterKey, GroupLinkPassword password
98 ) throws IOException, GroupLinkNotActiveException {
99 var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
100
101 return dependencies.getGroupsV2Api()
102 .getGroupJoinInfo(groupSecretParams,
103 Optional.ofNullable(password).map(GroupLinkPassword::serialize),
104 getGroupAuthForToday(groupSecretParams));
105 }
106
107 GroupHistoryPage getDecryptedGroupHistoryPage(
108 final GroupSecretParams groupSecretParams, int fromRevision
109 ) throws NotAGroupMemberException {
110 try {
111 final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
112 return dependencies.getGroupsV2Api()
113 .getGroupHistoryPage(groupSecretParams, fromRevision, groupsV2AuthorizationString, false);
114 } catch (NonSuccessfulResponseCodeException e) {
115 if (e.getCode() == 403) {
116 throw new NotAGroupMemberException(GroupUtils.getGroupIdV2(groupSecretParams), null);
117 }
118 logger.warn("Failed to retrieve Group V2 history, ignoring: {}", e.getMessage());
119 return null;
120 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
121 logger.warn("Failed to retrieve Group V2 history, ignoring: {}", e.getMessage());
122 return null;
123 }
124 }
125
126 int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) {
127 ByteString bytes = UuidUtil.toByteString(getSelfAci().uuid());
128 for (DecryptedMember decryptedMember : partialDecryptedGroup.getMembersList()) {
129 if (decryptedMember.getUuid().equals(bytes)) {
130 return decryptedMember.getJoinedAtRevision();
131 }
132 }
133 return partialDecryptedGroup.getRevision();
134 }
135
136 Pair<GroupInfoV2, DecryptedGroup> createGroup(
137 String name, Set<RecipientId> members, File avatarFile
138 ) throws IOException {
139 final var avatarBytes = readAvatarBytes(avatarFile);
140 final var newGroup = buildNewGroup(name, members, avatarBytes);
141 if (newGroup == null) {
142 return null;
143 }
144
145 final var groupSecretParams = newGroup.getGroupSecretParams();
146
147 final GroupsV2AuthorizationString groupAuthForToday;
148 final DecryptedGroup decryptedGroup;
149 try {
150 groupAuthForToday = getGroupAuthForToday(groupSecretParams);
151 dependencies.getGroupsV2Api().putNewGroup(newGroup, groupAuthForToday);
152 decryptedGroup = dependencies.getGroupsV2Api().getGroup(groupSecretParams, groupAuthForToday);
153 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
154 logger.warn("Failed to create V2 group: {}", e.getMessage());
155 return null;
156 }
157 if (decryptedGroup == null) {
158 logger.warn("Failed to create V2 group, unknown error!");
159 return null;
160 }
161
162 final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
163 final var masterKey = groupSecretParams.getMasterKey();
164 var g = new GroupInfoV2(groupId, masterKey, context.getAccount().getRecipientResolver());
165
166 return new Pair<>(g, decryptedGroup);
167 }
168
169 private byte[] readAvatarBytes(final File avatarFile) throws IOException {
170 final byte[] avatarBytes;
171 try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
172 avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
173 }
174 return avatarBytes;
175 }
176
177 private GroupsV2Operations.NewGroup buildNewGroup(
178 String name, Set<RecipientId> members, byte[] avatar
179 ) {
180 final var profileKeyCredential = context.getProfileHelper()
181 .getExpiringProfileKeyCredential(context.getAccount().getSelfRecipientId());
182 if (profileKeyCredential == null) {
183 logger.warn("Cannot create a V2 group as self does not have a versioned profile");
184 return null;
185 }
186
187 final var self = new GroupCandidate(getSelfAci().uuid(), Optional.of(profileKeyCredential));
188 final var memberList = new ArrayList<>(members);
189 final var credentials = context.getProfileHelper().getExpiringProfileKeyCredential(memberList).stream();
190 final var uuids = memberList.stream()
191 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid());
192 var candidates = Utils.zip(uuids,
193 credentials,
194 (uuid, credential) -> new GroupCandidate(uuid, Optional.ofNullable(credential)))
195 .collect(Collectors.toSet());
196
197 final var groupSecretParams = GroupSecretParams.generate();
198 return dependencies.getGroupsV2Operations()
199 .createNewGroup(groupSecretParams,
200 name,
201 Optional.ofNullable(avatar),
202 self,
203 candidates,
204 Member.Role.DEFAULT,
205 0);
206 }
207
208 Pair<DecryptedGroup, GroupChange> updateGroup(
209 GroupInfoV2 groupInfoV2, String name, String description, File avatarFile
210 ) throws IOException {
211 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
212 var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
213
214 var change = name != null ? groupOperations.createModifyGroupTitle(name) : GroupChange.Actions.newBuilder();
215
216 if (description != null) {
217 change.setModifyDescription(groupOperations.createModifyGroupDescriptionAction(description));
218 }
219
220 if (avatarFile != null) {
221 final var avatarBytes = readAvatarBytes(avatarFile);
222 var avatarCdnKey = dependencies.getGroupsV2Api()
223 .uploadAvatar(avatarBytes, groupSecretParams, getGroupAuthForToday(groupSecretParams));
224 change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
225 }
226
227 change.setSourceUuid(getSelfAci().toByteString());
228
229 return commitChange(groupInfoV2, change);
230 }
231
232 Pair<DecryptedGroup, GroupChange> addMembers(
233 GroupInfoV2 groupInfoV2, Set<RecipientId> newMembers
234 ) throws IOException {
235 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
236
237 final var memberList = new ArrayList<>(newMembers);
238 final var credentials = context.getProfileHelper().getExpiringProfileKeyCredential(memberList).stream();
239 final var uuids = memberList.stream()
240 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid());
241 var candidates = Utils.zip(uuids,
242 credentials,
243 (uuid, credential) -> new GroupCandidate(uuid, Optional.ofNullable(credential)))
244 .collect(Collectors.toSet());
245 final var bannedUuids = groupInfoV2.getBannedMembers()
246 .stream()
247 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid())
248 .collect(Collectors.toSet());
249
250 final var aci = getSelfAci();
251 final var change = groupOperations.createModifyGroupMembershipChange(candidates, bannedUuids, aci.uuid());
252
253 change.setSourceUuid(getSelfAci().toByteString());
254
255 return commitChange(groupInfoV2, change);
256 }
257
258 Pair<DecryptedGroup, GroupChange> leaveGroup(
259 GroupInfoV2 groupInfoV2, Set<RecipientId> membersToMakeAdmin
260 ) throws IOException {
261 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
262 final var selfAci = getSelfAci();
263 var selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfAci.uuid());
264
265 if (selfPendingMember.isPresent()) {
266 return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
267 }
268
269 final var adminUuids = membersToMakeAdmin.stream()
270 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
271 .map(SignalServiceAddress::getServiceId)
272 .map(ServiceId::uuid)
273 .toList();
274 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
275 return commitChange(groupInfoV2,
276 groupOperations.createLeaveAndPromoteMembersToAdmin(selfAci.uuid(), adminUuids));
277 }
278
279 Pair<DecryptedGroup, GroupChange> removeMembers(
280 GroupInfoV2 groupInfoV2, Set<RecipientId> members
281 ) throws IOException {
282 final var memberUuids = members.stream()
283 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
284 .map(SignalServiceAddress::getServiceId)
285 .map(ServiceId::uuid)
286 .collect(Collectors.toSet());
287 return ejectMembers(groupInfoV2, memberUuids);
288 }
289
290 Pair<DecryptedGroup, GroupChange> approveJoinRequestMembers(
291 GroupInfoV2 groupInfoV2, Set<RecipientId> members
292 ) throws IOException {
293 final var memberUuids = members.stream()
294 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
295 .map(SignalServiceAddress::getServiceId)
296 .map(ServiceId::uuid)
297 .collect(Collectors.toSet());
298 return approveJoinRequest(groupInfoV2, memberUuids);
299 }
300
301 Pair<DecryptedGroup, GroupChange> refuseJoinRequestMembers(
302 GroupInfoV2 groupInfoV2, Set<RecipientId> members
303 ) throws IOException {
304 final var memberUuids = members.stream()
305 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
306 .map(SignalServiceAddress::getServiceId)
307 .map(ServiceId::uuid)
308 .collect(Collectors.toSet());
309 return refuseJoinRequest(groupInfoV2, memberUuids);
310 }
311
312 Pair<DecryptedGroup, GroupChange> revokeInvitedMembers(
313 GroupInfoV2 groupInfoV2, Set<RecipientId> members
314 ) throws IOException {
315 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
316 final var memberUuids = members.stream()
317 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
318 .map(SignalServiceAddress::getServiceId)
319 .map(ServiceId::uuid)
320 .map(uuid -> DecryptedGroupUtil.findPendingByUuid(pendingMembersList, uuid))
321 .filter(Optional::isPresent)
322 .map(Optional::get)
323 .collect(Collectors.toSet());
324 return revokeInvites(groupInfoV2, memberUuids);
325 }
326
327 Pair<DecryptedGroup, GroupChange> banMembers(
328 GroupInfoV2 groupInfoV2, Set<RecipientId> block
329 ) throws IOException {
330 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
331
332 final var uuids = block.stream()
333 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid())
334 .collect(Collectors.toSet());
335
336 final var change = groupOperations.createBanUuidsChange(uuids,
337 false,
338 groupInfoV2.getGroup().getBannedMembersList());
339
340 change.setSourceUuid(getSelfAci().toByteString());
341
342 return commitChange(groupInfoV2, change);
343 }
344
345 Pair<DecryptedGroup, GroupChange> unbanMembers(
346 GroupInfoV2 groupInfoV2, Set<RecipientId> block
347 ) throws IOException {
348 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
349
350 final var uuids = block.stream()
351 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid())
352 .collect(Collectors.toSet());
353
354 final var change = groupOperations.createUnbanUuidsChange(uuids);
355
356 change.setSourceUuid(getSelfAci().toByteString());
357
358 return commitChange(groupInfoV2, change);
359 }
360
361 Pair<DecryptedGroup, GroupChange> resetGroupLinkPassword(GroupInfoV2 groupInfoV2) throws IOException {
362 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
363 final var newGroupLinkPassword = GroupLinkPassword.createNew().serialize();
364 final var change = groupOperations.createModifyGroupLinkPasswordChange(newGroupLinkPassword);
365 return commitChange(groupInfoV2, change);
366 }
367
368 Pair<DecryptedGroup, GroupChange> setGroupLinkState(
369 GroupInfoV2 groupInfoV2, GroupLinkState state
370 ) throws IOException {
371 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
372
373 final var accessRequired = toAccessControl(state);
374 final var requiresNewPassword = state != GroupLinkState.DISABLED && groupInfoV2.getGroup()
375 .getInviteLinkPassword()
376 .isEmpty();
377
378 final var change = requiresNewPassword ? groupOperations.createModifyGroupLinkPasswordAndRightsChange(
379 GroupLinkPassword.createNew().serialize(),
380 accessRequired) : groupOperations.createChangeJoinByLinkRights(accessRequired);
381 return commitChange(groupInfoV2, change);
382 }
383
384 Pair<DecryptedGroup, GroupChange> setEditDetailsPermission(
385 GroupInfoV2 groupInfoV2, GroupPermission permission
386 ) throws IOException {
387 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
388
389 final var accessRequired = toAccessControl(permission);
390 final var change = groupOperations.createChangeAttributesRights(accessRequired);
391 return commitChange(groupInfoV2, change);
392 }
393
394 Pair<DecryptedGroup, GroupChange> setAddMemberPermission(
395 GroupInfoV2 groupInfoV2, GroupPermission permission
396 ) throws IOException {
397 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
398
399 final var accessRequired = toAccessControl(permission);
400 final var change = groupOperations.createChangeMembershipRights(accessRequired);
401 return commitChange(groupInfoV2, change);
402 }
403
404 Pair<DecryptedGroup, GroupChange> updateSelfProfileKey(GroupInfoV2 groupInfoV2) throws IOException {
405 Optional<DecryptedMember> selfInGroup = groupInfoV2.getGroup() == null
406 ? Optional.empty()
407 : DecryptedGroupUtil.findMemberByUuid(groupInfoV2.getGroup().getMembersList(), getSelfAci().uuid());
408 if (selfInGroup.isEmpty()) {
409 logger.trace("Not updating group, self not in group " + groupInfoV2.getGroupId().toBase64());
410 return null;
411 }
412
413 final var profileKey = context.getAccount().getProfileKey();
414 if (Arrays.equals(profileKey.serialize(), selfInGroup.get().getProfileKey().toByteArray())) {
415 logger.trace("Not updating group, own Profile Key is already up to date in group "
416 + groupInfoV2.getGroupId().toBase64());
417 return null;
418 }
419 logger.debug("Updating own profile key in group " + groupInfoV2.getGroupId().toBase64());
420
421 final var selfRecipientId = context.getAccount().getSelfRecipientId();
422 final var profileKeyCredential = context.getProfileHelper().getExpiringProfileKeyCredential(selfRecipientId);
423 if (profileKeyCredential == null) {
424 logger.trace("Cannot update profile key as self does not have a versioned profile");
425 return null;
426 }
427
428 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
429 final var change = groupOperations.createUpdateProfileKeyCredentialChange(profileKeyCredential);
430 change.setSourceUuid(getSelfAci().toByteString());
431 return commitChange(groupInfoV2, change);
432 }
433
434 GroupChange joinGroup(
435 GroupMasterKey groupMasterKey,
436 GroupLinkPassword groupLinkPassword,
437 DecryptedGroupJoinInfo decryptedGroupJoinInfo
438 ) throws IOException {
439 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
440 final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
441
442 final var selfRecipientId = context.getAccount().getSelfRecipientId();
443 final var profileKeyCredential = context.getProfileHelper().getExpiringProfileKeyCredential(selfRecipientId);
444 if (profileKeyCredential == null) {
445 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
446 }
447
448 var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
449 var change = requestToJoin
450 ? groupOperations.createGroupJoinRequest(profileKeyCredential)
451 : groupOperations.createGroupJoinDirect(profileKeyCredential);
452
453 change.setSourceUuid(context.getRecipientHelper()
454 .resolveSignalServiceAddress(selfRecipientId)
455 .getServiceId()
456 .toByteString());
457
458 return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
459 }
460
461 Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
462 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
463
464 final var selfRecipientId = context.getAccount().getSelfRecipientId();
465 final var profileKeyCredential = context.getProfileHelper().getExpiringProfileKeyCredential(selfRecipientId);
466 if (profileKeyCredential == null) {
467 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
468 }
469
470 final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
471
472 final var aci = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId).getServiceId();
473 change.setSourceUuid(aci.toByteString());
474
475 return commitChange(groupInfoV2, change);
476 }
477
478 Pair<DecryptedGroup, GroupChange> setMemberAdmin(
479 GroupInfoV2 groupInfoV2, RecipientId recipientId, boolean admin
480 ) throws IOException {
481 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
482 final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
483 final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
484 final var change = groupOperations.createChangeMemberRole(address.getServiceId().uuid(), newRole);
485 return commitChange(groupInfoV2, change);
486 }
487
488 Pair<DecryptedGroup, GroupChange> setMessageExpirationTimer(
489 GroupInfoV2 groupInfoV2, int messageExpirationTimer
490 ) throws IOException {
491 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
492 final var change = groupOperations.createModifyGroupTimerChange(messageExpirationTimer);
493 return commitChange(groupInfoV2, change);
494 }
495
496 Pair<DecryptedGroup, GroupChange> setIsAnnouncementGroup(
497 GroupInfoV2 groupInfoV2, boolean isAnnouncementGroup
498 ) throws IOException {
499 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
500 final var change = groupOperations.createAnnouncementGroupChange(isAnnouncementGroup);
501 return commitChange(groupInfoV2, change);
502 }
503
504 private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) {
505 return switch (state) {
506 case DISABLED -> AccessControl.AccessRequired.UNSATISFIABLE;
507 case ENABLED -> AccessControl.AccessRequired.ANY;
508 case ENABLED_WITH_APPROVAL -> AccessControl.AccessRequired.ADMINISTRATOR;
509 };
510 }
511
512 private AccessControl.AccessRequired toAccessControl(final GroupPermission permission) {
513 return switch (permission) {
514 case EVERY_MEMBER -> AccessControl.AccessRequired.MEMBER;
515 case ONLY_ADMINS -> AccessControl.AccessRequired.ADMINISTRATOR;
516 };
517 }
518
519 private GroupsV2Operations.GroupOperations getGroupOperations(final GroupInfoV2 groupInfoV2) {
520 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
521 return dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
522 }
523
524 private Pair<DecryptedGroup, GroupChange> revokeInvites(
525 GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
526 ) throws IOException {
527 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
528 final var uuidCipherTexts = pendingMembers.stream().map(member -> {
529 try {
530 return new UuidCiphertext(member.getUuidCipherText().toByteArray());
531 } catch (InvalidInputException e) {
532 throw new AssertionError(e);
533 }
534 }).collect(Collectors.toSet());
535 return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
536 }
537
538 private Pair<DecryptedGroup, GroupChange> approveJoinRequest(
539 GroupInfoV2 groupInfoV2, Set<UUID> uuids
540 ) throws IOException {
541 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
542 return commitChange(groupInfoV2, groupOperations.createApproveGroupJoinRequest(uuids));
543 }
544
545 private Pair<DecryptedGroup, GroupChange> refuseJoinRequest(
546 GroupInfoV2 groupInfoV2, Set<UUID> uuids
547 ) throws IOException {
548 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
549 return commitChange(groupInfoV2, groupOperations.createRefuseGroupJoinRequest(uuids, false, List.of()));
550 }
551
552 private Pair<DecryptedGroup, GroupChange> ejectMembers(
553 GroupInfoV2 groupInfoV2, Set<UUID> uuids
554 ) throws IOException {
555 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
556 return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids, false, List.of()));
557 }
558
559 private Pair<DecryptedGroup, GroupChange> commitChange(
560 GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
561 ) throws IOException {
562 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
563 final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
564 final var previousGroupState = groupInfoV2.getGroup();
565 final var nextRevision = previousGroupState.getRevision() + 1;
566 final var changeActions = change.setRevision(nextRevision).build();
567 final DecryptedGroupChange decryptedChange;
568 final DecryptedGroup decryptedGroupState;
569
570 try {
571 decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci().uuid());
572 decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
573 } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
574 throw new IOException(e);
575 }
576
577 var signedGroupChange = dependencies.getGroupsV2Api()
578 .patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
579
580 return new Pair<>(decryptedGroupState, signedGroupChange);
581 }
582
583 private GroupChange commitChange(
584 GroupSecretParams groupSecretParams,
585 int currentRevision,
586 GroupChange.Actions.Builder change,
587 GroupLinkPassword password
588 ) throws IOException {
589 final var nextRevision = currentRevision + 1;
590 final var changeActions = change.setRevision(nextRevision).build();
591
592 return dependencies.getGroupsV2Api()
593 .patchGroup(changeActions,
594 getGroupAuthForToday(groupSecretParams),
595 Optional.ofNullable(password).map(GroupLinkPassword::serialize));
596 }
597
598 Pair<ServiceId, ProfileKey> getAuthoritativeProfileKeyFromChange(final DecryptedGroupChange change) {
599 UUID editor = UuidUtil.fromByteStringOrNull(change.getEditor());
600 final var editorProfileKeyBytes = Stream.concat(Stream.of(change.getNewMembersList().stream(),
601 change.getPromotePendingMembersList().stream(),
602 change.getModifiedProfileKeysList().stream())
603 .flatMap(Function.identity())
604 .filter(m -> UuidUtil.fromByteString(m.getUuid()).equals(editor))
605 .map(DecryptedMember::getProfileKey),
606 change.getNewRequestingMembersList()
607 .stream()
608 .filter(m -> UuidUtil.fromByteString(m.getUuid()).equals(editor))
609 .map(DecryptedRequestingMember::getProfileKey)).findFirst();
610
611 if (editorProfileKeyBytes.isEmpty()) {
612 return null;
613 }
614
615 ProfileKey profileKey;
616 try {
617 profileKey = new ProfileKey(editorProfileKeyBytes.get().toByteArray());
618 } catch (InvalidInputException e) {
619 logger.debug("Bad profile key in group");
620 return null;
621 }
622
623 return new Pair<>(ServiceId.from(editor), profileKey);
624 }
625
626 DecryptedGroup getUpdatedDecryptedGroup(DecryptedGroup group, DecryptedGroupChange decryptedGroupChange) {
627 try {
628 return DecryptedGroupUtil.apply(group, decryptedGroupChange);
629 } catch (NotAbleToApplyGroupV2ChangeException e) {
630 return null;
631 }
632 }
633
634 DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
635 if (signedGroupChange != null) {
636 var groupOperations = dependencies.getGroupsV2Operations()
637 .forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
638
639 try {
640 return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orElse(null);
641 } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
642 return null;
643 }
644 }
645
646 return null;
647 }
648
649 private static long currentDaySeconds() {
650 return TimeUnit.DAYS.toSeconds(TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()));
651 }
652
653 private GroupsV2AuthorizationString getGroupAuthForToday(
654 final GroupSecretParams groupSecretParams
655 ) throws IOException {
656 final var todaySeconds = currentDaySeconds();
657 if (groupApiCredentials == null || !groupApiCredentials.containsKey(todaySeconds)) {
658 // Returns credentials for the next 7 days
659 groupApiCredentials = dependencies.getGroupsV2Api().getCredentials(todaySeconds);
660 // TODO cache credentials on disk until they expire
661 }
662 try {
663 return getAuthorizationString(groupSecretParams, todaySeconds);
664 } catch (VerificationFailedException e) {
665 logger.debug("Group api credentials invalid, renewing and trying again.");
666 groupApiCredentials.clear();
667 }
668
669 groupApiCredentials = dependencies.getGroupsV2Api().getCredentials(todaySeconds);
670 try {
671 return getAuthorizationString(groupSecretParams, todaySeconds);
672 } catch (VerificationFailedException e) {
673 throw new IOException(e);
674 }
675 }
676
677 private GroupsV2AuthorizationString getAuthorizationString(
678 final GroupSecretParams groupSecretParams, final long todaySeconds
679 ) throws VerificationFailedException {
680 var authCredentialResponse = groupApiCredentials.get(todaySeconds);
681 final var aci = getSelfAci();
682 final var pni = getSelfPni();
683 return dependencies.getGroupsV2Api()
684 .getGroupsV2AuthorizationString(aci, pni, todaySeconds, groupSecretParams, authCredentialResponse);
685 }
686
687 private ACI getSelfAci() {
688 return context.getAccount().getAci();
689 }
690
691 private PNI getSelfPni() {
692 return context.getAccount().getPni();
693 }
694 }