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