]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
Implement configuring of group link
[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.groups.GroupLinkPassword;
6 import org.asamk.signal.manager.groups.GroupLinkState;
7 import org.asamk.signal.manager.groups.GroupUtils;
8 import org.asamk.signal.manager.storage.groups.GroupInfoV2;
9 import org.asamk.signal.manager.storage.recipients.Profile;
10 import org.asamk.signal.manager.storage.recipients.RecipientId;
11 import org.asamk.signal.manager.util.IOUtils;
12 import org.signal.storageservice.protos.groups.AccessControl;
13 import org.signal.storageservice.protos.groups.GroupChange;
14 import org.signal.storageservice.protos.groups.Member;
15 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
16 import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
17 import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
18 import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
19 import org.signal.zkgroup.InvalidInputException;
20 import org.signal.zkgroup.VerificationFailedException;
21 import org.signal.zkgroup.groups.GroupMasterKey;
22 import org.signal.zkgroup.groups.GroupSecretParams;
23 import org.signal.zkgroup.groups.UuidCiphertext;
24 import org.slf4j.Logger;
25 import org.slf4j.LoggerFactory;
26 import org.whispersystems.libsignal.util.Pair;
27 import org.whispersystems.libsignal.util.guava.Optional;
28 import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
29 import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
30 import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
31 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
32 import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
33 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
34 import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
35 import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
36 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
37 import org.whispersystems.signalservice.api.util.UuidUtil;
38
39 import java.io.File;
40 import java.io.FileInputStream;
41 import java.io.IOException;
42 import java.io.InputStream;
43 import java.util.Set;
44 import java.util.UUID;
45 import java.util.stream.Collectors;
46
47 public class GroupV2Helper {
48
49 private final static Logger logger = LoggerFactory.getLogger(GroupV2Helper.class);
50
51 private final ProfileKeyCredentialProvider profileKeyCredentialProvider;
52
53 private final ProfileProvider profileProvider;
54
55 private final SelfRecipientIdProvider selfRecipientIdProvider;
56
57 private final GroupsV2Operations groupsV2Operations;
58
59 private final GroupsV2Api groupsV2Api;
60
61 private final GroupAuthorizationProvider groupAuthorizationProvider;
62
63 private final SignalServiceAddressResolver addressResolver;
64
65 public GroupV2Helper(
66 final ProfileKeyCredentialProvider profileKeyCredentialProvider,
67 final ProfileProvider profileProvider,
68 final SelfRecipientIdProvider selfRecipientIdProvider,
69 final GroupsV2Operations groupsV2Operations,
70 final GroupsV2Api groupsV2Api,
71 final GroupAuthorizationProvider groupAuthorizationProvider,
72 final SignalServiceAddressResolver addressResolver
73 ) {
74 this.profileKeyCredentialProvider = profileKeyCredentialProvider;
75 this.profileProvider = profileProvider;
76 this.selfRecipientIdProvider = selfRecipientIdProvider;
77 this.groupsV2Operations = groupsV2Operations;
78 this.groupsV2Api = groupsV2Api;
79 this.groupAuthorizationProvider = groupAuthorizationProvider;
80 this.addressResolver = addressResolver;
81 }
82
83 public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) {
84 try {
85 final var groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday(
86 groupSecretParams);
87 return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
88 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
89 logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
90 return null;
91 }
92 }
93
94 public DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
95 GroupMasterKey groupMasterKey, GroupLinkPassword password
96 ) throws IOException, GroupLinkNotActiveException {
97 var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
98
99 return groupsV2Api.getGroupJoinInfo(groupSecretParams,
100 Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
101 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
102 }
103
104 public Pair<GroupInfoV2, DecryptedGroup> createGroup(
105 String name, Set<RecipientId> members, File avatarFile
106 ) throws IOException {
107 final var avatarBytes = readAvatarBytes(avatarFile);
108 final var newGroup = buildNewGroup(name, members, avatarBytes);
109 if (newGroup == null) {
110 return null;
111 }
112
113 final var groupSecretParams = newGroup.getGroupSecretParams();
114
115 final GroupsV2AuthorizationString groupAuthForToday;
116 final DecryptedGroup decryptedGroup;
117 try {
118 groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams);
119 groupsV2Api.putNewGroup(newGroup, groupAuthForToday);
120 decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday);
121 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
122 logger.warn("Failed to create V2 group: {}", e.getMessage());
123 return null;
124 }
125 if (decryptedGroup == null) {
126 logger.warn("Failed to create V2 group, unknown error!");
127 return null;
128 }
129
130 final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
131 final var masterKey = groupSecretParams.getMasterKey();
132 var g = new GroupInfoV2(groupId, masterKey);
133
134 return new Pair<>(g, decryptedGroup);
135 }
136
137 private byte[] readAvatarBytes(final File avatarFile) throws IOException {
138 final byte[] avatarBytes;
139 try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
140 avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
141 }
142 return avatarBytes;
143 }
144
145 private GroupsV2Operations.NewGroup buildNewGroup(
146 String name, Set<RecipientId> members, byte[] avatar
147 ) {
148 final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientIdProvider.getSelfRecipientId());
149 if (profileKeyCredential == null) {
150 logger.warn("Cannot create a V2 group as self does not have a versioned profile");
151 return null;
152 }
153
154 if (!areMembersValid(members)) return null;
155
156 var self = new GroupCandidate(addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
157 .getUuid()
158 .orNull(), Optional.fromNullable(profileKeyCredential));
159 var candidates = members.stream()
160 .map(member -> new GroupCandidate(addressResolver.resolveSignalServiceAddress(member).getUuid().get(),
161 Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
162 .collect(Collectors.toSet());
163
164 final var groupSecretParams = GroupSecretParams.generate();
165 return groupsV2Operations.createNewGroup(groupSecretParams,
166 name,
167 Optional.fromNullable(avatar),
168 self,
169 candidates,
170 Member.Role.DEFAULT,
171 0);
172 }
173
174 private boolean areMembersValid(final Set<RecipientId> members) {
175 final var noUuidCapability = members.stream()
176 .map(addressResolver::resolveSignalServiceAddress)
177 .filter(address -> !address.getUuid().isPresent())
178 .map(SignalServiceAddress::getLegacyIdentifier)
179 .collect(Collectors.toSet());
180 if (noUuidCapability.size() > 0) {
181 logger.warn("Cannot create a V2 group as some members don't have a UUID: {}",
182 String.join(", ", noUuidCapability));
183 return false;
184 }
185
186 final var noGv2Capability = members.stream()
187 .map(profileProvider::getProfile)
188 .filter(profile -> profile != null && !profile.getCapabilities().contains(Profile.Capability.gv2))
189 .collect(Collectors.toSet());
190 if (noGv2Capability.size() > 0) {
191 logger.warn("Cannot create a V2 group as some members don't support Groups V2: {}",
192 noGv2Capability.stream().map(Profile::getDisplayName).collect(Collectors.joining(", ")));
193 return false;
194 }
195
196 return true;
197 }
198
199 public Pair<DecryptedGroup, GroupChange> updateGroup(
200 GroupInfoV2 groupInfoV2, String name, String description, File avatarFile
201 ) throws IOException {
202 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
203 var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
204
205 var change = name != null ? groupOperations.createModifyGroupTitle(name) : GroupChange.Actions.newBuilder();
206
207 if (description != null) {
208 change.setModifyDescription(groupOperations.createModifyGroupDescription(description));
209 }
210
211 if (avatarFile != null) {
212 final var avatarBytes = readAvatarBytes(avatarFile);
213 var avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes,
214 groupSecretParams,
215 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
216 change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
217 }
218
219 final var uuid = addressResolver.resolveSignalServiceAddress(this.selfRecipientIdProvider.getSelfRecipientId())
220 .getUuid();
221 if (uuid.isPresent()) {
222 change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
223 }
224
225 return commitChange(groupInfoV2, change);
226 }
227
228 public Pair<DecryptedGroup, GroupChange> addMembers(
229 GroupInfoV2 groupInfoV2, Set<RecipientId> newMembers
230 ) throws IOException {
231 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
232
233 if (!areMembersValid(newMembers)) {
234 throw new IOException("Failed to update group");
235 }
236
237 var candidates = newMembers.stream()
238 .map(member -> new GroupCandidate(addressResolver.resolveSignalServiceAddress(member).getUuid().get(),
239 Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
240 .collect(Collectors.toSet());
241
242 final var uuid = addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
243 .getUuid()
244 .get();
245 final var change = groupOperations.createModifyGroupMembershipChange(candidates, uuid);
246
247 change.setSourceUuid(UuidUtil.toByteString(uuid));
248
249 return commitChange(groupInfoV2, change);
250 }
251
252 public Pair<DecryptedGroup, GroupChange> leaveGroup(GroupInfoV2 groupInfoV2) throws IOException {
253 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
254 final var selfUuid = addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
255 .getUuid()
256 .get();
257 var selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfUuid);
258
259 if (selfPendingMember.isPresent()) {
260 return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
261 } else {
262 return ejectMembers(groupInfoV2, Set.of(selfUuid));
263 }
264 }
265
266 public Pair<DecryptedGroup, GroupChange> removeMembers(
267 GroupInfoV2 groupInfoV2, Set<RecipientId> members
268 ) throws IOException {
269 final var memberUuids = members.stream()
270 .map(addressResolver::resolveSignalServiceAddress)
271 .map(SignalServiceAddress::getUuid)
272 .filter(Optional::isPresent)
273 .map(Optional::get)
274 .collect(Collectors.toSet());
275 return ejectMembers(groupInfoV2, memberUuids);
276 }
277
278 public Pair<DecryptedGroup, GroupChange> revokeInvitedMembers(
279 GroupInfoV2 groupInfoV2, Set<RecipientId> members
280 ) throws IOException {
281 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
282 final var memberUuids = members.stream()
283 .map(addressResolver::resolveSignalServiceAddress)
284 .map(SignalServiceAddress::getUuid)
285 .filter(Optional::isPresent)
286 .map(Optional::get)
287 .map(uuid -> DecryptedGroupUtil.findPendingByUuid(pendingMembersList, uuid))
288 .filter(Optional::isPresent)
289 .map(Optional::get)
290 .collect(Collectors.toSet());
291 return revokeInvites(groupInfoV2, memberUuids);
292 }
293
294 public Pair<DecryptedGroup, GroupChange> resetGroupLinkPassword(GroupInfoV2 groupInfoV2) throws IOException {
295 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
296 final var newGroupLinkPassword = GroupLinkPassword.createNew().serialize();
297 final var change = groupOperations.createModifyGroupLinkPasswordChange(newGroupLinkPassword);
298 return commitChange(groupInfoV2, change);
299 }
300
301 public Pair<DecryptedGroup, GroupChange> setGroupLinkState(
302 GroupInfoV2 groupInfoV2, GroupLinkState state
303 ) throws IOException {
304 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
305
306 final var accessRequired = toAccessControl(state);
307 final var requiresNewPassword = state != GroupLinkState.DISABLED && groupInfoV2.getGroup()
308 .getInviteLinkPassword()
309 .isEmpty();
310
311 final var change = requiresNewPassword ? groupOperations.createModifyGroupLinkPasswordAndRightsChange(
312 GroupLinkPassword.createNew().serialize(),
313 accessRequired) : groupOperations.createChangeJoinByLinkRights(accessRequired);
314 return commitChange(groupInfoV2, change);
315 }
316
317 public GroupChange joinGroup(
318 GroupMasterKey groupMasterKey,
319 GroupLinkPassword groupLinkPassword,
320 DecryptedGroupJoinInfo decryptedGroupJoinInfo
321 ) throws IOException {
322 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
323 final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
324
325 final var selfRecipientId = this.selfRecipientIdProvider.getSelfRecipientId();
326 final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
327 if (profileKeyCredential == null) {
328 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
329 }
330
331 var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
332 var change = requestToJoin
333 ? groupOperations.createGroupJoinRequest(profileKeyCredential)
334 : groupOperations.createGroupJoinDirect(profileKeyCredential);
335
336 change.setSourceUuid(UuidUtil.toByteString(addressResolver.resolveSignalServiceAddress(selfRecipientId)
337 .getUuid()
338 .get()));
339
340 return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
341 }
342
343 public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
344 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
345
346 final var selfRecipientId = this.selfRecipientIdProvider.getSelfRecipientId();
347 final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
348 if (profileKeyCredential == null) {
349 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
350 }
351
352 final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
353
354 final var uuid = addressResolver.resolveSignalServiceAddress(selfRecipientId).getUuid();
355 if (uuid.isPresent()) {
356 change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
357 }
358
359 return commitChange(groupInfoV2, change);
360 }
361
362 public Pair<DecryptedGroup, GroupChange> setMemberAdmin(
363 GroupInfoV2 groupInfoV2, RecipientId recipientId, boolean admin
364 ) throws IOException {
365 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
366 final var address = addressResolver.resolveSignalServiceAddress(recipientId);
367 final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
368 final var change = groupOperations.createChangeMemberRole(address.getUuid().get(), newRole);
369 return commitChange(groupInfoV2, change);
370 }
371
372 private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) {
373 switch (state) {
374 case DISABLED:
375 return AccessControl.AccessRequired.UNSATISFIABLE;
376 case ENABLED:
377 return AccessControl.AccessRequired.ANY;
378 case ENABLED_WITH_APPROVAL:
379 return AccessControl.AccessRequired.ADMINISTRATOR;
380 default:
381 throw new AssertionError();
382 }
383 }
384
385 private GroupsV2Operations.GroupOperations getGroupOperations(final GroupInfoV2 groupInfoV2) {
386 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
387 return groupsV2Operations.forGroup(groupSecretParams);
388 }
389
390 private Pair<DecryptedGroup, GroupChange> revokeInvites(
391 GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
392 ) throws IOException {
393 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
394 final var uuidCipherTexts = pendingMembers.stream().map(member -> {
395 try {
396 return new UuidCiphertext(member.getUuidCipherText().toByteArray());
397 } catch (InvalidInputException e) {
398 throw new AssertionError(e);
399 }
400 }).collect(Collectors.toSet());
401 return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
402 }
403
404 private Pair<DecryptedGroup, GroupChange> ejectMembers(
405 GroupInfoV2 groupInfoV2, Set<UUID> uuids
406 ) throws IOException {
407 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
408 return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
409 }
410
411 private Pair<DecryptedGroup, GroupChange> commitChange(
412 GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
413 ) throws IOException {
414 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
415 final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
416 final var previousGroupState = groupInfoV2.getGroup();
417 final var nextRevision = previousGroupState.getRevision() + 1;
418 final var changeActions = change.setRevision(nextRevision).build();
419 final DecryptedGroupChange decryptedChange;
420 final DecryptedGroup decryptedGroupState;
421
422 try {
423 decryptedChange = groupOperations.decryptChange(changeActions,
424 addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
425 .getUuid()
426 .get());
427 decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
428 } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
429 throw new IOException(e);
430 }
431
432 var signedGroupChange = groupsV2Api.patchGroup(changeActions,
433 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
434 Optional.absent());
435
436 return new Pair<>(decryptedGroupState, signedGroupChange);
437 }
438
439 private GroupChange commitChange(
440 GroupSecretParams groupSecretParams,
441 int currentRevision,
442 GroupChange.Actions.Builder change,
443 GroupLinkPassword password
444 ) throws IOException {
445 final var nextRevision = currentRevision + 1;
446 final var changeActions = change.setRevision(nextRevision).build();
447
448 return groupsV2Api.patchGroup(changeActions,
449 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
450 Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
451 }
452
453 public DecryptedGroup getUpdatedDecryptedGroup(
454 DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
455 ) {
456 try {
457 final var decryptedGroupChange = getDecryptedGroupChange(signedGroupChange, groupMasterKey);
458 if (decryptedGroupChange == null) {
459 return null;
460 }
461 return DecryptedGroupUtil.apply(group, decryptedGroupChange);
462 } catch (NotAbleToApplyGroupV2ChangeException e) {
463 return null;
464 }
465 }
466
467 private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
468 if (signedGroupChange != null) {
469 var groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
470
471 try {
472 return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
473 } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
474 return null;
475 }
476 }
477
478 return null;
479 }
480 }