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