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