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