]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
Update libsignal-service
[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.api.GroupLinkState;
7 import org.asamk.signal.manager.api.GroupPermission;
8 import org.asamk.signal.manager.api.NotAGroupMemberException;
9 import org.asamk.signal.manager.api.Pair;
10 import org.asamk.signal.manager.groups.GroupLinkPassword;
11 import org.asamk.signal.manager.groups.GroupUtils;
12 import org.asamk.signal.manager.internal.SignalDependencies;
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.Utils;
16 import org.signal.libsignal.zkgroup.InvalidInputException;
17 import org.signal.libsignal.zkgroup.VerificationFailedException;
18 import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
19 import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
20 import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
21 import org.signal.libsignal.zkgroup.groups.UuidCiphertext;
22 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
23 import org.signal.storageservice.protos.groups.AccessControl;
24 import org.signal.storageservice.protos.groups.GroupChange;
25 import org.signal.storageservice.protos.groups.Member;
26 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
27 import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
28 import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
29 import org.signal.storageservice.protos.groups.local.DecryptedMember;
30 import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
31 import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
34 import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
35 import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
36 import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage;
37 import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
38 import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
39 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
40 import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
41 import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
42 import org.whispersystems.signalservice.api.push.ServiceId;
43 import org.whispersystems.signalservice.api.push.ServiceId.ACI;
44 import org.whispersystems.signalservice.api.push.ServiceId.PNI;
45 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
46 import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
47 import org.whispersystems.signalservice.api.util.UuidUtil;
48
49 import java.io.IOException;
50 import java.util.ArrayList;
51 import java.util.Arrays;
52 import java.util.List;
53 import java.util.Map;
54 import java.util.Optional;
55 import java.util.Set;
56 import java.util.UUID;
57 import java.util.concurrent.TimeUnit;
58 import java.util.function.Function;
59 import java.util.stream.Collectors;
60 import java.util.stream.Stream;
61
62 class GroupV2Helper {
63
64 private final static 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 DecryptedGroup 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 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
109 ) throws NotAGroupMemberException {
110 try {
111 final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
112 return dependencies.getGroupsV2Api()
113 .getGroupHistoryPage(groupSecretParams, fromRevision, groupsV2AuthorizationString, false);
114 } catch (NonSuccessfulResponseCodeException e) {
115 if (e.getCode() == 403) {
116 throw new NotAGroupMemberException(GroupUtils.getGroupIdV2(groupSecretParams), null);
117 }
118 logger.warn("Failed to retrieve Group V2 history, ignoring: {}", e.getMessage());
119 return null;
120 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
121 logger.warn("Failed to retrieve Group V2 history, ignoring: {}", e.getMessage());
122 return null;
123 }
124 }
125
126 int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) {
127 ByteString bytes = getSelfAci().toByteString();
128 for (DecryptedMember decryptedMember : partialDecryptedGroup.getMembersList()) {
129 if (decryptedMember.getUuid().equals(bytes)) {
130 return decryptedMember.getJoinedAtRevision();
131 }
132 }
133 return partialDecryptedGroup.getRevision();
134 }
135
136 Pair<GroupInfoV2, DecryptedGroup> createGroup(
137 String name, Set<RecipientId> members, byte[] avatarFile
138 ) {
139 final var newGroup = buildNewGroup(name, members, avatarFile);
140 if (newGroup == null) {
141 return null;
142 }
143
144 final var groupSecretParams = newGroup.getGroupSecretParams();
145
146 final GroupsV2AuthorizationString groupAuthForToday;
147 final DecryptedGroup decryptedGroup;
148 try {
149 groupAuthForToday = getGroupAuthForToday(groupSecretParams);
150 dependencies.getGroupsV2Api().putNewGroup(newGroup, groupAuthForToday);
151 decryptedGroup = dependencies.getGroupsV2Api().getGroup(groupSecretParams, groupAuthForToday);
152 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
153 logger.warn("Failed to create V2 group: {}", e.getMessage());
154 return null;
155 }
156 if (decryptedGroup == null) {
157 logger.warn("Failed to create V2 group, unknown error!");
158 return null;
159 }
160
161 final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
162 final var masterKey = groupSecretParams.getMasterKey();
163 var g = new GroupInfoV2(groupId, masterKey, context.getAccount().getRecipientResolver());
164
165 return new Pair<>(g, decryptedGroup);
166 }
167
168 private GroupsV2Operations.NewGroup buildNewGroup(
169 String name, Set<RecipientId> members, byte[] avatar
170 ) {
171 final var profileKeyCredential = context.getProfileHelper()
172 .getExpiringProfileKeyCredential(context.getAccount().getSelfRecipientId());
173 if (profileKeyCredential == null) {
174 logger.warn("Cannot create a V2 group as self does not have a versioned profile");
175 return null;
176 }
177
178 final var self = new GroupCandidate(getSelfAci(), Optional.of(profileKeyCredential));
179 final var memberList = new ArrayList<>(members);
180 final var credentials = context.getProfileHelper().getExpiringProfileKeyCredential(memberList).stream();
181 final var uuids = memberList.stream()
182 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId());
183 var candidates = Utils.zip(uuids,
184 credentials,
185 (uuid, credential) -> new GroupCandidate(uuid, Optional.ofNullable(credential)))
186 .collect(Collectors.toSet());
187
188 final var groupSecretParams = GroupSecretParams.generate();
189 return dependencies.getGroupsV2Operations()
190 .createNewGroup(groupSecretParams,
191 name,
192 Optional.ofNullable(avatar),
193 self,
194 candidates,
195 Member.Role.DEFAULT,
196 0);
197 }
198
199 Pair<DecryptedGroup, GroupChange> updateGroup(
200 GroupInfoV2 groupInfoV2, String name, String description, byte[] avatarFile
201 ) throws IOException {
202 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
203 var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
204
205 var change = name != null ? groupOperations.createModifyGroupTitle(name) : GroupChange.Actions.newBuilder();
206
207 if (description != null) {
208 change.setModifyDescription(groupOperations.createModifyGroupDescriptionAction(description));
209 }
210
211 if (avatarFile != null) {
212 var avatarCdnKey = dependencies.getGroupsV2Api()
213 .uploadAvatar(avatarFile, groupSecretParams, getGroupAuthForToday(groupSecretParams));
214 change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
215 }
216
217 change.setSourceUuid(getSelfAci().toByteString());
218
219 return commitChange(groupInfoV2, change);
220 }
221
222 Pair<DecryptedGroup, GroupChange> addMembers(
223 GroupInfoV2 groupInfoV2, Set<RecipientId> newMembers
224 ) throws IOException {
225 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
226
227 final var memberList = new ArrayList<>(newMembers);
228 final var credentials = context.getProfileHelper().getExpiringProfileKeyCredential(memberList).stream();
229 final var uuids = memberList.stream()
230 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId());
231 var candidates = Utils.zip(uuids,
232 credentials,
233 (uuid, credential) -> new GroupCandidate(uuid, Optional.ofNullable(credential)))
234 .collect(Collectors.toSet());
235 final var bannedUuids = groupInfoV2.getBannedMembers()
236 .stream()
237 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId())
238 .collect(Collectors.toSet());
239
240 final var aci = getSelfAci();
241 final var change = groupOperations.createModifyGroupMembershipChange(candidates, bannedUuids, aci);
242
243 change.setSourceUuid(getSelfAci().toByteString());
244
245 return commitChange(groupInfoV2, change);
246 }
247
248 Pair<DecryptedGroup, GroupChange> leaveGroup(
249 GroupInfoV2 groupInfoV2, Set<RecipientId> membersToMakeAdmin
250 ) throws IOException {
251 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
252 final var selfAci = getSelfAci();
253 var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
254
255 if (selfPendingMember.isPresent()) {
256 return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
257 }
258
259 final var adminUuids = membersToMakeAdmin.stream()
260 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
261 .map(SignalServiceAddress::getServiceId)
262 .map(ServiceId::getRawUuid)
263 .toList();
264 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
265 return commitChange(groupInfoV2,
266 groupOperations.createLeaveAndPromoteMembersToAdmin(selfAci.getRawUuid(), adminUuids));
267 }
268
269 Pair<DecryptedGroup, GroupChange> removeMembers(
270 GroupInfoV2 groupInfoV2, Set<RecipientId> members
271 ) throws IOException {
272 final var memberUuids = members.stream()
273 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
274 .map(SignalServiceAddress::getServiceId)
275 .map(ServiceId::getRawUuid)
276 .collect(Collectors.toSet());
277 return ejectMembers(groupInfoV2, memberUuids);
278 }
279
280 Pair<DecryptedGroup, GroupChange> approveJoinRequestMembers(
281 GroupInfoV2 groupInfoV2, Set<RecipientId> members
282 ) throws IOException {
283 final var memberUuids = members.stream()
284 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
285 .map(SignalServiceAddress::getServiceId)
286 .map(ServiceId::getRawUuid)
287 .collect(Collectors.toSet());
288 return approveJoinRequest(groupInfoV2, memberUuids);
289 }
290
291 Pair<DecryptedGroup, GroupChange> refuseJoinRequestMembers(
292 GroupInfoV2 groupInfoV2, Set<RecipientId> members
293 ) throws IOException {
294 final var memberUuids = members.stream()
295 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
296 .map(SignalServiceAddress::getServiceId)
297 .map(ServiceId::getRawUuid)
298 .collect(Collectors.toSet());
299 return refuseJoinRequest(groupInfoV2, memberUuids);
300 }
301
302 Pair<DecryptedGroup, GroupChange> revokeInvitedMembers(
303 GroupInfoV2 groupInfoV2, Set<RecipientId> members
304 ) throws IOException {
305 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
306 final var memberUuids = members.stream()
307 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
308 .map(SignalServiceAddress::getServiceId)
309 .map(uuid -> DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, uuid))
310 .filter(Optional::isPresent)
311 .map(Optional::get)
312 .collect(Collectors.toSet());
313 return revokeInvites(groupInfoV2, memberUuids);
314 }
315
316 Pair<DecryptedGroup, GroupChange> banMembers(
317 GroupInfoV2 groupInfoV2, Set<RecipientId> block
318 ) throws IOException {
319 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
320
321 final var uuids = block.stream()
322 .map(member -> context.getRecipientHelper()
323 .resolveSignalServiceAddress(member)
324 .getServiceId()
325 .getRawUuid())
326 .collect(Collectors.toSet());
327
328 final var change = groupOperations.createBanUuidsChange(uuids,
329 false,
330 groupInfoV2.getGroup().getBannedMembersList());
331
332 change.setSourceUuid(getSelfAci().toByteString());
333
334 return commitChange(groupInfoV2, change);
335 }
336
337 Pair<DecryptedGroup, GroupChange> unbanMembers(
338 GroupInfoV2 groupInfoV2, Set<RecipientId> block
339 ) throws IOException {
340 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
341
342 final var serviceIds = block.stream()
343 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId())
344 .collect(Collectors.toSet());
345
346 final var change = groupOperations.createUnbanServiceIdsChange(serviceIds);
347
348 change.setSourceUuid(getSelfAci().toByteString());
349
350 return commitChange(groupInfoV2, change);
351 }
352
353 Pair<DecryptedGroup, GroupChange> resetGroupLinkPassword(GroupInfoV2 groupInfoV2) throws IOException {
354 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
355 final var newGroupLinkPassword = GroupLinkPassword.createNew().serialize();
356 final var change = groupOperations.createModifyGroupLinkPasswordChange(newGroupLinkPassword);
357 return commitChange(groupInfoV2, change);
358 }
359
360 Pair<DecryptedGroup, GroupChange> setGroupLinkState(
361 GroupInfoV2 groupInfoV2, GroupLinkState state
362 ) throws IOException {
363 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
364
365 final var accessRequired = toAccessControl(state);
366 final var requiresNewPassword = state != GroupLinkState.DISABLED && groupInfoV2.getGroup()
367 .getInviteLinkPassword()
368 .isEmpty();
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, GroupChange> 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, GroupChange> 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, GroupChange> updateSelfProfileKey(GroupInfoV2 groupInfoV2) throws IOException {
397 Optional<DecryptedMember> selfInGroup = groupInfoV2.getGroup() == null
398 ? Optional.empty()
399 : DecryptedGroupUtil.findMemberByUuid(groupInfoV2.getGroup().getMembersList(),
400 getSelfAci().getRawUuid());
401 if (selfInGroup.isEmpty()) {
402 logger.trace("Not updating group, self not in group " + groupInfoV2.getGroupId().toBase64());
403 return null;
404 }
405
406 final var profileKey = context.getAccount().getProfileKey();
407 if (Arrays.equals(profileKey.serialize(), selfInGroup.get().getProfileKey().toByteArray())) {
408 logger.trace("Not updating group, own Profile Key is already up to date in group "
409 + groupInfoV2.getGroupId().toBase64());
410 return null;
411 }
412 logger.debug("Updating own profile key in group " + groupInfoV2.getGroupId().toBase64());
413
414 final var selfRecipientId = context.getAccount().getSelfRecipientId();
415 final var profileKeyCredential = context.getProfileHelper().getExpiringProfileKeyCredential(selfRecipientId);
416 if (profileKeyCredential == null) {
417 logger.trace("Cannot update profile key as self does not have a versioned profile");
418 return null;
419 }
420
421 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
422 final var change = groupOperations.createUpdateProfileKeyCredentialChange(profileKeyCredential);
423 change.setSourceUuid(getSelfAci().toByteString());
424 return commitChange(groupInfoV2, change);
425 }
426
427 GroupChange joinGroup(
428 GroupMasterKey groupMasterKey,
429 GroupLinkPassword groupLinkPassword,
430 DecryptedGroupJoinInfo decryptedGroupJoinInfo
431 ) throws IOException {
432 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
433 final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
434
435 final var selfRecipientId = context.getAccount().getSelfRecipientId();
436 final var profileKeyCredential = context.getProfileHelper().getExpiringProfileKeyCredential(selfRecipientId);
437 if (profileKeyCredential == null) {
438 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
439 }
440
441 var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
442 var change = requestToJoin
443 ? groupOperations.createGroupJoinRequest(profileKeyCredential)
444 : groupOperations.createGroupJoinDirect(profileKeyCredential);
445
446 change.setSourceUuid(context.getRecipientHelper()
447 .resolveSignalServiceAddress(selfRecipientId)
448 .getServiceId()
449 .toByteString());
450
451 return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
452 }
453
454 Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
455 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
456
457 final var selfRecipientId = context.getAccount().getSelfRecipientId();
458 final var profileKeyCredential = context.getProfileHelper().getExpiringProfileKeyCredential(selfRecipientId);
459 if (profileKeyCredential == null) {
460 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
461 }
462
463 final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
464
465 final var aci = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId).getServiceId();
466 change.setSourceUuid(aci.toByteString());
467
468 return commitChange(groupInfoV2, change);
469 }
470
471 Pair<DecryptedGroup, GroupChange> setMemberAdmin(
472 GroupInfoV2 groupInfoV2, RecipientId recipientId, boolean admin
473 ) throws IOException {
474 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
475 final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
476 final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
477 if (address.getServiceId() instanceof ACI aci) {
478 final var change = groupOperations.createChangeMemberRole(aci, newRole);
479 return commitChange(groupInfoV2, change);
480 } else {
481 throw new IllegalArgumentException("Can't make a PNI a group admin.");
482 }
483 }
484
485 Pair<DecryptedGroup, GroupChange> setMessageExpirationTimer(
486 GroupInfoV2 groupInfoV2, int messageExpirationTimer
487 ) throws IOException {
488 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
489 final var change = groupOperations.createModifyGroupTimerChange(messageExpirationTimer);
490 return commitChange(groupInfoV2, change);
491 }
492
493 Pair<DecryptedGroup, GroupChange> setIsAnnouncementGroup(
494 GroupInfoV2 groupInfoV2, boolean isAnnouncementGroup
495 ) throws IOException {
496 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
497 final var change = groupOperations.createAnnouncementGroupChange(isAnnouncementGroup);
498 return commitChange(groupInfoV2, change);
499 }
500
501 private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) {
502 return switch (state) {
503 case DISABLED -> AccessControl.AccessRequired.UNSATISFIABLE;
504 case ENABLED -> AccessControl.AccessRequired.ANY;
505 case ENABLED_WITH_APPROVAL -> AccessControl.AccessRequired.ADMINISTRATOR;
506 };
507 }
508
509 private AccessControl.AccessRequired toAccessControl(final GroupPermission permission) {
510 return switch (permission) {
511 case EVERY_MEMBER -> AccessControl.AccessRequired.MEMBER;
512 case ONLY_ADMINS -> AccessControl.AccessRequired.ADMINISTRATOR;
513 };
514 }
515
516 private GroupsV2Operations.GroupOperations getGroupOperations(final GroupInfoV2 groupInfoV2) {
517 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
518 return dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
519 }
520
521 private Pair<DecryptedGroup, GroupChange> revokeInvites(
522 GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
523 ) throws IOException {
524 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
525 final var uuidCipherTexts = pendingMembers.stream().map(member -> {
526 try {
527 return new UuidCiphertext(member.getUuidCipherText().toByteArray());
528 } catch (InvalidInputException e) {
529 throw new AssertionError(e);
530 }
531 }).collect(Collectors.toSet());
532 return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
533 }
534
535 private Pair<DecryptedGroup, GroupChange> approveJoinRequest(
536 GroupInfoV2 groupInfoV2, Set<UUID> uuids
537 ) throws IOException {
538 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
539 return commitChange(groupInfoV2, groupOperations.createApproveGroupJoinRequest(uuids));
540 }
541
542 private Pair<DecryptedGroup, GroupChange> refuseJoinRequest(
543 GroupInfoV2 groupInfoV2, Set<UUID> uuids
544 ) throws IOException {
545 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
546 return commitChange(groupInfoV2, groupOperations.createRefuseGroupJoinRequest(uuids, false, List.of()));
547 }
548
549 private Pair<DecryptedGroup, GroupChange> ejectMembers(
550 GroupInfoV2 groupInfoV2, Set<UUID> uuids
551 ) throws IOException {
552 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
553 return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids, false, List.of()));
554 }
555
556 private Pair<DecryptedGroup, GroupChange> commitChange(
557 GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
558 ) throws IOException {
559 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
560 final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
561 final var previousGroupState = groupInfoV2.getGroup();
562 final var nextRevision = previousGroupState.getRevision() + 1;
563 final var changeActions = change.setRevision(nextRevision).build();
564 final DecryptedGroupChange decryptedChange;
565 final DecryptedGroup decryptedGroupState;
566
567 try {
568 decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci());
569 decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
570 } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
571 throw new IOException(e);
572 }
573
574 var signedGroupChange = dependencies.getGroupsV2Api()
575 .patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
576
577 return new Pair<>(decryptedGroupState, signedGroupChange);
578 }
579
580 private GroupChange commitChange(
581 GroupSecretParams groupSecretParams,
582 int currentRevision,
583 GroupChange.Actions.Builder change,
584 GroupLinkPassword password
585 ) throws IOException {
586 final var nextRevision = currentRevision + 1;
587 final var changeActions = change.setRevision(nextRevision).build();
588
589 return dependencies.getGroupsV2Api()
590 .patchGroup(changeActions,
591 getGroupAuthForToday(groupSecretParams),
592 Optional.ofNullable(password).map(GroupLinkPassword::serialize));
593 }
594
595 Pair<ServiceId, ProfileKey> getAuthoritativeProfileKeyFromChange(final DecryptedGroupChange change) {
596 UUID editor = UuidUtil.fromByteStringOrNull(change.getEditor());
597 final var editorProfileKeyBytes = Stream.concat(Stream.of(change.getNewMembersList().stream(),
598 change.getPromotePendingMembersList().stream(),
599 change.getModifiedProfileKeysList().stream())
600 .flatMap(Function.identity())
601 .filter(m -> UuidUtil.fromByteString(m.getUuid()).equals(editor))
602 .map(DecryptedMember::getProfileKey),
603 change.getNewRequestingMembersList()
604 .stream()
605 .filter(m -> UuidUtil.fromByteString(m.getUuid()).equals(editor))
606 .map(DecryptedRequestingMember::getProfileKey)).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.parseFrom(signedGroupChange), true).orElse(null);
638 } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException 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 }