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