]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
9b934580aaad0b293104e317866670de4e73bc2e
[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.AuthCredentialResponse;
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.ServiceId;
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.File;
50 import java.io.FileInputStream;
51 import java.io.IOException;
52 import java.io.InputStream;
53 import java.util.ArrayList;
54 import java.util.Arrays;
55 import java.util.HashMap;
56 import java.util.List;
57 import java.util.Optional;
58 import java.util.Set;
59 import java.util.UUID;
60 import java.util.concurrent.TimeUnit;
61 import java.util.function.Function;
62 import java.util.stream.Collectors;
63 import java.util.stream.Stream;
64
65 class GroupV2Helper {
66
67 private final static Logger logger = LoggerFactory.getLogger(GroupV2Helper.class);
68
69 private final SignalDependencies dependencies;
70 private final Context context;
71
72 private HashMap<Integer, AuthCredentialResponse> groupApiCredentials;
73
74 GroupV2Helper(final Context context) {
75 this.dependencies = context.getDependencies();
76 this.context = context;
77 }
78
79 DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) throws NotAGroupMemberException {
80 try {
81 final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
82 return dependencies.getGroupsV2Api().getGroup(groupSecretParams, groupsV2AuthorizationString);
83 } catch (NonSuccessfulResponseCodeException e) {
84 if (e.getCode() == 403) {
85 throw new NotAGroupMemberException(GroupUtils.getGroupIdV2(groupSecretParams), null);
86 }
87 logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
88 return null;
89 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
90 logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
91 return null;
92 }
93 }
94
95 DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
96 GroupMasterKey groupMasterKey, GroupLinkPassword password
97 ) throws IOException, GroupLinkNotActiveException {
98 var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
99
100 return dependencies.getGroupsV2Api()
101 .getGroupJoinInfo(groupSecretParams,
102 Optional.ofNullable(password).map(GroupLinkPassword::serialize),
103 getGroupAuthForToday(groupSecretParams));
104 }
105
106 GroupHistoryPage getDecryptedGroupHistoryPage(
107 final GroupSecretParams groupSecretParams, int fromRevision
108 ) throws NotAGroupMemberException {
109 try {
110 final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
111 return dependencies.getGroupsV2Api()
112 .getGroupHistoryPage(groupSecretParams, fromRevision, groupsV2AuthorizationString, false);
113 } catch (NonSuccessfulResponseCodeException e) {
114 if (e.getCode() == 403) {
115 throw new NotAGroupMemberException(GroupUtils.getGroupIdV2(groupSecretParams), null);
116 }
117 logger.warn("Failed to retrieve Group V2 history, ignoring: {}", e.getMessage());
118 return null;
119 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
120 logger.warn("Failed to retrieve Group V2 history, ignoring: {}", e.getMessage());
121 return null;
122 }
123 }
124
125 int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) {
126 ByteString bytes = UuidUtil.toByteString(getSelfAci().uuid());
127 for (DecryptedMember decryptedMember : partialDecryptedGroup.getMembersList()) {
128 if (decryptedMember.getUuid().equals(bytes)) {
129 return decryptedMember.getJoinedAtRevision();
130 }
131 }
132 return partialDecryptedGroup.getRevision();
133 }
134
135 Pair<GroupInfoV2, DecryptedGroup> createGroup(
136 String name, Set<RecipientId> members, File avatarFile
137 ) throws IOException {
138 final var avatarBytes = readAvatarBytes(avatarFile);
139 final var newGroup = buildNewGroup(name, members, avatarBytes);
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);
164
165 return new Pair<>(g, decryptedGroup);
166 }
167
168 private byte[] readAvatarBytes(final File avatarFile) throws IOException {
169 final byte[] avatarBytes;
170 try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
171 avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
172 }
173 return avatarBytes;
174 }
175
176 private GroupsV2Operations.NewGroup buildNewGroup(
177 String name, Set<RecipientId> members, byte[] avatar
178 ) {
179 final var profileKeyCredential = context.getProfileHelper()
180 .getRecipientProfileKeyCredential(context.getAccount().getSelfRecipientId());
181 if (profileKeyCredential == null) {
182 logger.warn("Cannot create a V2 group as self does not have a versioned profile");
183 return null;
184 }
185
186 final var self = new GroupCandidate(getSelfAci().uuid(), Optional.of(profileKeyCredential));
187 final var memberList = new ArrayList<>(members);
188 final var credentials = context.getProfileHelper().getRecipientProfileKeyCredential(memberList).stream();
189 final var uuids = memberList.stream()
190 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid());
191 var candidates = Utils.zip(uuids,
192 credentials,
193 (uuid, credential) -> new GroupCandidate(uuid, Optional.ofNullable(credential)))
194 .collect(Collectors.toSet());
195
196 final var groupSecretParams = GroupSecretParams.generate();
197 return dependencies.getGroupsV2Operations()
198 .createNewGroup(groupSecretParams,
199 name,
200 Optional.ofNullable(avatar),
201 self,
202 candidates,
203 Member.Role.DEFAULT,
204 0);
205 }
206
207 Pair<DecryptedGroup, GroupChange> updateGroup(
208 GroupInfoV2 groupInfoV2, String name, String description, File avatarFile
209 ) throws IOException {
210 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
211 var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
212
213 var change = name != null ? groupOperations.createModifyGroupTitle(name) : GroupChange.Actions.newBuilder();
214
215 if (description != null) {
216 change.setModifyDescription(groupOperations.createModifyGroupDescriptionAction(description));
217 }
218
219 if (avatarFile != null) {
220 final var avatarBytes = readAvatarBytes(avatarFile);
221 var avatarCdnKey = dependencies.getGroupsV2Api()
222 .uploadAvatar(avatarBytes, groupSecretParams, getGroupAuthForToday(groupSecretParams));
223 change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
224 }
225
226 change.setSourceUuid(getSelfAci().toByteString());
227
228 return commitChange(groupInfoV2, change);
229 }
230
231 Pair<DecryptedGroup, GroupChange> addMembers(
232 GroupInfoV2 groupInfoV2, Set<RecipientId> newMembers
233 ) throws IOException {
234 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
235
236 final var memberList = new ArrayList<>(newMembers);
237 final var credentials = context.getProfileHelper().getRecipientProfileKeyCredential(memberList).stream();
238 final var uuids = memberList.stream()
239 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid());
240 var candidates = Utils.zip(uuids,
241 credentials,
242 (uuid, credential) -> new GroupCandidate(uuid, Optional.ofNullable(credential)))
243 .collect(Collectors.toSet());
244 final var bannedUuids = groupInfoV2.getBannedMembers()
245 .stream()
246 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid())
247 .collect(Collectors.toSet());
248
249 final var aci = getSelfAci();
250 final var change = groupOperations.createModifyGroupMembershipChange(candidates, bannedUuids, aci.uuid());
251
252 change.setSourceUuid(getSelfAci().toByteString());
253
254 return commitChange(groupInfoV2, change);
255 }
256
257 Pair<DecryptedGroup, GroupChange> leaveGroup(
258 GroupInfoV2 groupInfoV2, Set<RecipientId> membersToMakeAdmin
259 ) throws IOException {
260 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
261 final var selfAci = getSelfAci();
262 var selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfAci.uuid());
263
264 if (selfPendingMember.isPresent()) {
265 return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
266 }
267
268 final var adminUuids = membersToMakeAdmin.stream()
269 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
270 .map(SignalServiceAddress::getServiceId)
271 .map(ServiceId::uuid)
272 .toList();
273 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
274 return commitChange(groupInfoV2,
275 groupOperations.createLeaveAndPromoteMembersToAdmin(selfAci.uuid(), adminUuids));
276 }
277
278 Pair<DecryptedGroup, GroupChange> removeMembers(
279 GroupInfoV2 groupInfoV2, Set<RecipientId> members
280 ) throws IOException {
281 final var memberUuids = members.stream()
282 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
283 .map(SignalServiceAddress::getServiceId)
284 .map(ServiceId::uuid)
285 .collect(Collectors.toSet());
286 return ejectMembers(groupInfoV2, memberUuids);
287 }
288
289 Pair<DecryptedGroup, GroupChange> revokeInvitedMembers(
290 GroupInfoV2 groupInfoV2, Set<RecipientId> members
291 ) throws IOException {
292 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
293 final var memberUuids = members.stream()
294 .map(context.getRecipientHelper()::resolveSignalServiceAddress)
295 .map(SignalServiceAddress::getServiceId)
296 .map(ServiceId::uuid)
297 .map(uuid -> DecryptedGroupUtil.findPendingByUuid(pendingMembersList, uuid))
298 .filter(Optional::isPresent)
299 .map(Optional::get)
300 .collect(Collectors.toSet());
301 return revokeInvites(groupInfoV2, memberUuids);
302 }
303
304 Pair<DecryptedGroup, GroupChange> banMembers(
305 GroupInfoV2 groupInfoV2, Set<RecipientId> block
306 ) throws IOException {
307 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
308
309 final var uuids = block.stream()
310 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid())
311 .collect(Collectors.toSet());
312
313 final var change = groupOperations.createBanUuidsChange(uuids,
314 false,
315 groupInfoV2.getGroup().getBannedMembersList());
316
317 change.setSourceUuid(getSelfAci().toByteString());
318
319 return commitChange(groupInfoV2, change);
320 }
321
322 Pair<DecryptedGroup, GroupChange> unbanMembers(
323 GroupInfoV2 groupInfoV2, Set<RecipientId> block
324 ) throws IOException {
325 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
326
327 final var uuids = block.stream()
328 .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId().uuid())
329 .collect(Collectors.toSet());
330
331 final var change = groupOperations.createUnbanUuidsChange(uuids);
332
333 change.setSourceUuid(getSelfAci().toByteString());
334
335 return commitChange(groupInfoV2, change);
336 }
337
338 Pair<DecryptedGroup, GroupChange> resetGroupLinkPassword(GroupInfoV2 groupInfoV2) throws IOException {
339 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
340 final var newGroupLinkPassword = GroupLinkPassword.createNew().serialize();
341 final var change = groupOperations.createModifyGroupLinkPasswordChange(newGroupLinkPassword);
342 return commitChange(groupInfoV2, change);
343 }
344
345 Pair<DecryptedGroup, GroupChange> setGroupLinkState(
346 GroupInfoV2 groupInfoV2, GroupLinkState state
347 ) throws IOException {
348 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
349
350 final var accessRequired = toAccessControl(state);
351 final var requiresNewPassword = state != GroupLinkState.DISABLED && groupInfoV2.getGroup()
352 .getInviteLinkPassword()
353 .isEmpty();
354
355 final var change = requiresNewPassword ? groupOperations.createModifyGroupLinkPasswordAndRightsChange(
356 GroupLinkPassword.createNew().serialize(),
357 accessRequired) : groupOperations.createChangeJoinByLinkRights(accessRequired);
358 return commitChange(groupInfoV2, change);
359 }
360
361 Pair<DecryptedGroup, GroupChange> setEditDetailsPermission(
362 GroupInfoV2 groupInfoV2, GroupPermission permission
363 ) throws IOException {
364 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
365
366 final var accessRequired = toAccessControl(permission);
367 final var change = groupOperations.createChangeAttributesRights(accessRequired);
368 return commitChange(groupInfoV2, change);
369 }
370
371 Pair<DecryptedGroup, GroupChange> setAddMemberPermission(
372 GroupInfoV2 groupInfoV2, GroupPermission permission
373 ) throws IOException {
374 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
375
376 final var accessRequired = toAccessControl(permission);
377 final var change = groupOperations.createChangeMembershipRights(accessRequired);
378 return commitChange(groupInfoV2, change);
379 }
380
381 Pair<DecryptedGroup, GroupChange> updateSelfProfileKey(GroupInfoV2 groupInfoV2) throws IOException {
382 Optional<DecryptedMember> selfInGroup = groupInfoV2.getGroup() == null
383 ? Optional.empty()
384 : DecryptedGroupUtil.findMemberByUuid(groupInfoV2.getGroup().getMembersList(), getSelfAci().uuid());
385 if (selfInGroup.isEmpty()) {
386 logger.trace("Not updating group, self not in group " + groupInfoV2.getGroupId().toBase64());
387 return null;
388 }
389
390 final var profileKey = context.getAccount().getProfileKey();
391 if (Arrays.equals(profileKey.serialize(), selfInGroup.get().getProfileKey().toByteArray())) {
392 logger.trace("Not updating group, own Profile Key is already up to date in group "
393 + groupInfoV2.getGroupId().toBase64());
394 return null;
395 }
396 logger.debug("Updating own profile key in group " + groupInfoV2.getGroupId().toBase64());
397
398 final var selfRecipientId = context.getAccount().getSelfRecipientId();
399 final var profileKeyCredential = context.getProfileHelper().getRecipientProfileKeyCredential(selfRecipientId);
400 if (profileKeyCredential == null) {
401 logger.trace("Cannot update profile key as self does not have a versioned profile");
402 return null;
403 }
404
405 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
406 final var change = groupOperations.createUpdateProfileKeyCredentialChange(profileKeyCredential);
407 change.setSourceUuid(getSelfAci().toByteString());
408 return commitChange(groupInfoV2, change);
409 }
410
411 GroupChange joinGroup(
412 GroupMasterKey groupMasterKey,
413 GroupLinkPassword groupLinkPassword,
414 DecryptedGroupJoinInfo decryptedGroupJoinInfo
415 ) throws IOException {
416 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
417 final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
418
419 final var selfRecipientId = context.getAccount().getSelfRecipientId();
420 final var profileKeyCredential = context.getProfileHelper().getRecipientProfileKeyCredential(selfRecipientId);
421 if (profileKeyCredential == null) {
422 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
423 }
424
425 var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
426 var change = requestToJoin
427 ? groupOperations.createGroupJoinRequest(profileKeyCredential)
428 : groupOperations.createGroupJoinDirect(profileKeyCredential);
429
430 change.setSourceUuid(context.getRecipientHelper()
431 .resolveSignalServiceAddress(selfRecipientId)
432 .getServiceId()
433 .toByteString());
434
435 return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
436 }
437
438 Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
439 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
440
441 final var selfRecipientId = context.getAccount().getSelfRecipientId();
442 final var profileKeyCredential = context.getProfileHelper().getRecipientProfileKeyCredential(selfRecipientId);
443 if (profileKeyCredential == null) {
444 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
445 }
446
447 final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
448
449 final var aci = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId).getServiceId();
450 change.setSourceUuid(aci.toByteString());
451
452 return commitChange(groupInfoV2, change);
453 }
454
455 Pair<DecryptedGroup, GroupChange> setMemberAdmin(
456 GroupInfoV2 groupInfoV2, RecipientId recipientId, boolean admin
457 ) throws IOException {
458 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
459 final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
460 final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
461 final var change = groupOperations.createChangeMemberRole(address.getServiceId().uuid(), newRole);
462 return commitChange(groupInfoV2, change);
463 }
464
465 Pair<DecryptedGroup, GroupChange> setMessageExpirationTimer(
466 GroupInfoV2 groupInfoV2, int messageExpirationTimer
467 ) throws IOException {
468 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
469 final var change = groupOperations.createModifyGroupTimerChange(messageExpirationTimer);
470 return commitChange(groupInfoV2, change);
471 }
472
473 Pair<DecryptedGroup, GroupChange> setIsAnnouncementGroup(
474 GroupInfoV2 groupInfoV2, boolean isAnnouncementGroup
475 ) throws IOException {
476 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
477 final var change = groupOperations.createAnnouncementGroupChange(isAnnouncementGroup);
478 return commitChange(groupInfoV2, change);
479 }
480
481 private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) {
482 return switch (state) {
483 case DISABLED -> AccessControl.AccessRequired.UNSATISFIABLE;
484 case ENABLED -> AccessControl.AccessRequired.ANY;
485 case ENABLED_WITH_APPROVAL -> AccessControl.AccessRequired.ADMINISTRATOR;
486 };
487 }
488
489 private AccessControl.AccessRequired toAccessControl(final GroupPermission permission) {
490 return switch (permission) {
491 case EVERY_MEMBER -> AccessControl.AccessRequired.MEMBER;
492 case ONLY_ADMINS -> AccessControl.AccessRequired.ADMINISTRATOR;
493 };
494 }
495
496 private GroupsV2Operations.GroupOperations getGroupOperations(final GroupInfoV2 groupInfoV2) {
497 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
498 return dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
499 }
500
501 private Pair<DecryptedGroup, GroupChange> revokeInvites(
502 GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
503 ) throws IOException {
504 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
505 final var uuidCipherTexts = pendingMembers.stream().map(member -> {
506 try {
507 return new UuidCiphertext(member.getUuidCipherText().toByteArray());
508 } catch (InvalidInputException e) {
509 throw new AssertionError(e);
510 }
511 }).collect(Collectors.toSet());
512 return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
513 }
514
515 private Pair<DecryptedGroup, GroupChange> ejectMembers(
516 GroupInfoV2 groupInfoV2, Set<UUID> uuids
517 ) throws IOException {
518 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
519 return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids, false, List.of()));
520 }
521
522 private Pair<DecryptedGroup, GroupChange> commitChange(
523 GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
524 ) throws IOException {
525 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
526 final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
527 final var previousGroupState = groupInfoV2.getGroup();
528 final var nextRevision = previousGroupState.getRevision() + 1;
529 final var changeActions = change.setRevision(nextRevision).build();
530 final DecryptedGroupChange decryptedChange;
531 final DecryptedGroup decryptedGroupState;
532
533 try {
534 decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci().uuid());
535 decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
536 } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
537 throw new IOException(e);
538 }
539
540 var signedGroupChange = dependencies.getGroupsV2Api()
541 .patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
542
543 return new Pair<>(decryptedGroupState, signedGroupChange);
544 }
545
546 private GroupChange commitChange(
547 GroupSecretParams groupSecretParams,
548 int currentRevision,
549 GroupChange.Actions.Builder change,
550 GroupLinkPassword password
551 ) throws IOException {
552 final var nextRevision = currentRevision + 1;
553 final var changeActions = change.setRevision(nextRevision).build();
554
555 return dependencies.getGroupsV2Api()
556 .patchGroup(changeActions,
557 getGroupAuthForToday(groupSecretParams),
558 Optional.ofNullable(password).map(GroupLinkPassword::serialize));
559 }
560
561 Pair<ServiceId, ProfileKey> getAuthoritativeProfileKeyFromChange(final DecryptedGroupChange change) {
562 UUID editor = UuidUtil.fromByteStringOrNull(change.getEditor());
563 final var editorProfileKeyBytes = Stream.concat(Stream.of(change.getNewMembersList().stream(),
564 change.getPromotePendingMembersList().stream(),
565 change.getModifiedProfileKeysList().stream())
566 .flatMap(Function.identity())
567 .filter(m -> UuidUtil.fromByteString(m.getUuid()).equals(editor))
568 .map(DecryptedMember::getProfileKey),
569 change.getNewRequestingMembersList()
570 .stream()
571 .filter(m -> UuidUtil.fromByteString(m.getUuid()).equals(editor))
572 .map(DecryptedRequestingMember::getProfileKey)).findFirst();
573
574 if (editorProfileKeyBytes.isEmpty()) {
575 return null;
576 }
577
578 ProfileKey profileKey;
579 try {
580 profileKey = new ProfileKey(editorProfileKeyBytes.get().toByteArray());
581 } catch (InvalidInputException e) {
582 logger.debug("Bad profile key in group");
583 return null;
584 }
585
586 return new Pair<>(ServiceId.from(editor), profileKey);
587 }
588
589 DecryptedGroup getUpdatedDecryptedGroup(DecryptedGroup group, DecryptedGroupChange decryptedGroupChange) {
590 try {
591 return DecryptedGroupUtil.apply(group, decryptedGroupChange);
592 } catch (NotAbleToApplyGroupV2ChangeException e) {
593 return null;
594 }
595 }
596
597 DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
598 if (signedGroupChange != null) {
599 var groupOperations = dependencies.getGroupsV2Operations()
600 .forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
601
602 try {
603 return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orElse(null);
604 } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
605 return null;
606 }
607 }
608
609 return null;
610 }
611
612 private static int currentTimeDays() {
613 return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis());
614 }
615
616 private GroupsV2AuthorizationString getGroupAuthForToday(
617 final GroupSecretParams groupSecretParams
618 ) throws IOException {
619 final var today = currentTimeDays();
620 if (groupApiCredentials == null || !groupApiCredentials.containsKey(today)) {
621 // Returns credentials for the next 7 days
622 final var isAci = true; // TODO enable group handling with PNI
623 groupApiCredentials = dependencies.getGroupsV2Api().getCredentials(today, isAci);
624 // TODO cache credentials on disk until they expire
625 }
626 var authCredentialResponse = groupApiCredentials.get(today);
627 final var aci = getSelfAci();
628 try {
629 return dependencies.getGroupsV2Api()
630 .getGroupsV2AuthorizationString(aci, today, groupSecretParams, authCredentialResponse);
631 } catch (VerificationFailedException e) {
632 throw new IOException(e);
633 }
634 }
635
636 private ACI getSelfAci() {
637 return context.getAccount().getAci();
638 }
639 }