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