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