]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Add group descriptions
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / GroupHelper.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.GroupUtils;
7 import org.asamk.signal.manager.storage.groups.GroupInfoV2;
8 import org.asamk.signal.manager.storage.recipients.Profile;
9 import org.asamk.signal.manager.storage.recipients.RecipientId;
10 import org.asamk.signal.manager.util.IOUtils;
11 import org.signal.storageservice.protos.groups.AccessControl;
12 import org.signal.storageservice.protos.groups.GroupChange;
13 import org.signal.storageservice.protos.groups.Member;
14 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
15 import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
16 import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
17 import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
18 import org.signal.zkgroup.InvalidInputException;
19 import org.signal.zkgroup.VerificationFailedException;
20 import org.signal.zkgroup.groups.GroupMasterKey;
21 import org.signal.zkgroup.groups.GroupSecretParams;
22 import org.signal.zkgroup.groups.UuidCiphertext;
23 import org.slf4j.Logger;
24 import org.slf4j.LoggerFactory;
25 import org.whispersystems.libsignal.util.Pair;
26 import org.whispersystems.libsignal.util.guava.Optional;
27 import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
28 import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
29 import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
30 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
31 import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
32 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
33 import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
34 import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
35 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
36 import org.whispersystems.signalservice.api.util.UuidUtil;
37
38 import java.io.File;
39 import java.io.FileInputStream;
40 import java.io.IOException;
41 import java.io.InputStream;
42 import java.util.Set;
43 import java.util.UUID;
44 import java.util.stream.Collectors;
45
46 public class GroupHelper {
47
48 private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class);
49
50 private final ProfileKeyCredentialProvider profileKeyCredentialProvider;
51
52 private final ProfileProvider profileProvider;
53
54 private final SelfRecipientIdProvider selfRecipientIdProvider;
55
56 private final GroupsV2Operations groupsV2Operations;
57
58 private final GroupsV2Api groupsV2Api;
59
60 private final GroupAuthorizationProvider groupAuthorizationProvider;
61
62 private final SignalServiceAddressResolver addressResolver;
63
64 public GroupHelper(
65 final ProfileKeyCredentialProvider profileKeyCredentialProvider,
66 final ProfileProvider profileProvider,
67 final SelfRecipientIdProvider selfRecipientIdProvider,
68 final GroupsV2Operations groupsV2Operations,
69 final GroupsV2Api groupsV2Api,
70 final GroupAuthorizationProvider groupAuthorizationProvider,
71 final SignalServiceAddressResolver addressResolver
72 ) {
73 this.profileKeyCredentialProvider = profileKeyCredentialProvider;
74 this.profileProvider = profileProvider;
75 this.selfRecipientIdProvider = selfRecipientIdProvider;
76 this.groupsV2Operations = groupsV2Operations;
77 this.groupsV2Api = groupsV2Api;
78 this.groupAuthorizationProvider = groupAuthorizationProvider;
79 this.addressResolver = addressResolver;
80 }
81
82 public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) {
83 try {
84 final var groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday(
85 groupSecretParams);
86 return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
87 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
88 logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
89 return null;
90 }
91 }
92
93 public DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
94 GroupMasterKey groupMasterKey, GroupLinkPassword password
95 ) throws IOException, GroupLinkNotActiveException {
96 var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
97
98 return groupsV2Api.getGroupJoinInfo(groupSecretParams,
99 Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
100 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
101 }
102
103 public Pair<GroupInfoV2, DecryptedGroup> createGroupV2(
104 String name, Set<RecipientId> members, File avatarFile
105 ) throws IOException {
106 final var avatarBytes = readAvatarBytes(avatarFile);
107 final var newGroup = buildNewGroupV2(name, members, avatarBytes);
108 if (newGroup == null) {
109 return null;
110 }
111
112 final var groupSecretParams = newGroup.getGroupSecretParams();
113
114 final GroupsV2AuthorizationString groupAuthForToday;
115 final DecryptedGroup decryptedGroup;
116 try {
117 groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams);
118 groupsV2Api.putNewGroup(newGroup, groupAuthForToday);
119 decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday);
120 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
121 logger.warn("Failed to create V2 group: {}", e.getMessage());
122 return null;
123 }
124 if (decryptedGroup == null) {
125 logger.warn("Failed to create V2 group, unknown error!");
126 return null;
127 }
128
129 final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
130 final var masterKey = groupSecretParams.getMasterKey();
131 var g = new GroupInfoV2(groupId, masterKey);
132
133 return new Pair<>(g, decryptedGroup);
134 }
135
136 private byte[] readAvatarBytes(final File avatarFile) throws IOException {
137 final byte[] avatarBytes;
138 try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
139 avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
140 }
141 return avatarBytes;
142 }
143
144 private GroupsV2Operations.NewGroup buildNewGroupV2(
145 String name, Set<RecipientId> members, byte[] avatar
146 ) {
147 final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientIdProvider.getSelfRecipientId());
148 if (profileKeyCredential == null) {
149 logger.warn("Cannot create a V2 group as self does not have a versioned profile");
150 return null;
151 }
152
153 if (!areMembersValid(members)) return null;
154
155 var self = new GroupCandidate(addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
156 .getUuid()
157 .orNull(), Optional.fromNullable(profileKeyCredential));
158 var candidates = members.stream()
159 .map(member -> new GroupCandidate(addressResolver.resolveSignalServiceAddress(member).getUuid().get(),
160 Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
161 .collect(Collectors.toSet());
162
163 final var groupSecretParams = GroupSecretParams.generate();
164 return groupsV2Operations.createNewGroup(groupSecretParams,
165 name,
166 Optional.fromNullable(avatar),
167 self,
168 candidates,
169 Member.Role.DEFAULT,
170 0);
171 }
172
173 private boolean areMembersValid(final Set<RecipientId> members) {
174 final var noUuidCapability = members.stream()
175 .map(addressResolver::resolveSignalServiceAddress)
176 .filter(address -> !address.getUuid().isPresent())
177 .map(SignalServiceAddress::getLegacyIdentifier)
178 .collect(Collectors.toSet());
179 if (noUuidCapability.size() > 0) {
180 logger.warn("Cannot create a V2 group as some members don't have a UUID: {}",
181 String.join(", ", noUuidCapability));
182 return false;
183 }
184
185 final var noGv2Capability = members.stream()
186 .map(profileProvider::getProfile)
187 .filter(profile -> profile != null && !profile.getCapabilities().contains(Profile.Capability.gv2))
188 .collect(Collectors.toSet());
189 if (noGv2Capability.size() > 0) {
190 logger.warn("Cannot create a V2 group as some members don't support Groups V2: {}",
191 noGv2Capability.stream().map(Profile::getDisplayName).collect(Collectors.joining(", ")));
192 return false;
193 }
194
195 return true;
196 }
197
198 public Pair<DecryptedGroup, GroupChange> updateGroupV2(
199 GroupInfoV2 groupInfoV2, String name, String description, File avatarFile
200 ) throws IOException {
201 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
202 var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
203
204 var change = name != null ? groupOperations.createModifyGroupTitle(name) : GroupChange.Actions.newBuilder();
205
206 if (description != null) {
207 change.setModifyDescription(groupOperations.createModifyGroupDescription(description));
208 }
209
210 if (avatarFile != null) {
211 final var avatarBytes = readAvatarBytes(avatarFile);
212 var avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes,
213 groupSecretParams,
214 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
215 change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
216 }
217
218 final var uuid = addressResolver.resolveSignalServiceAddress(this.selfRecipientIdProvider.getSelfRecipientId())
219 .getUuid();
220 if (uuid.isPresent()) {
221 change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
222 }
223
224 return commitChange(groupInfoV2, change);
225 }
226
227 public Pair<DecryptedGroup, GroupChange> updateGroupV2(
228 GroupInfoV2 groupInfoV2, Set<RecipientId> newMembers
229 ) throws IOException {
230 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
231 var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
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 GroupChange joinGroup(
267 GroupMasterKey groupMasterKey,
268 GroupLinkPassword groupLinkPassword,
269 DecryptedGroupJoinInfo decryptedGroupJoinInfo
270 ) throws IOException {
271 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
272 final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
273
274 final var selfRecipientId = this.selfRecipientIdProvider.getSelfRecipientId();
275 final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
276 if (profileKeyCredential == null) {
277 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
278 }
279
280 var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
281 var change = requestToJoin
282 ? groupOperations.createGroupJoinRequest(profileKeyCredential)
283 : groupOperations.createGroupJoinDirect(profileKeyCredential);
284
285 change.setSourceUuid(UuidUtil.toByteString(addressResolver.resolveSignalServiceAddress(selfRecipientId)
286 .getUuid()
287 .get()));
288
289 return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
290 }
291
292 public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
293 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
294 final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
295
296 final var selfRecipientId = this.selfRecipientIdProvider.getSelfRecipientId();
297 final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
298 if (profileKeyCredential == null) {
299 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
300 }
301
302 final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
303
304 final var uuid = addressResolver.resolveSignalServiceAddress(selfRecipientId).getUuid();
305 if (uuid.isPresent()) {
306 change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
307 }
308
309 return commitChange(groupInfoV2, change);
310 }
311
312 public Pair<DecryptedGroup, GroupChange> revokeInvites(
313 GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
314 ) throws IOException {
315 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
316 final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
317 final var uuidCipherTexts = pendingMembers.stream().map(member -> {
318 try {
319 return new UuidCiphertext(member.getUuidCipherText().toByteArray());
320 } catch (InvalidInputException e) {
321 throw new AssertionError(e);
322 }
323 }).collect(Collectors.toSet());
324 return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
325 }
326
327 public Pair<DecryptedGroup, GroupChange> ejectMembers(GroupInfoV2 groupInfoV2, Set<UUID> uuids) throws IOException {
328 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
329 final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
330 return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
331 }
332
333 private Pair<DecryptedGroup, GroupChange> commitChange(
334 GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
335 ) throws IOException {
336 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
337 final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
338 final var previousGroupState = groupInfoV2.getGroup();
339 final var nextRevision = previousGroupState.getRevision() + 1;
340 final var changeActions = change.setRevision(nextRevision).build();
341 final DecryptedGroupChange decryptedChange;
342 final DecryptedGroup decryptedGroupState;
343
344 try {
345 decryptedChange = groupOperations.decryptChange(changeActions,
346 addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
347 .getUuid()
348 .get());
349 decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
350 } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
351 throw new IOException(e);
352 }
353
354 var signedGroupChange = groupsV2Api.patchGroup(changeActions,
355 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
356 Optional.absent());
357
358 return new Pair<>(decryptedGroupState, signedGroupChange);
359 }
360
361 private GroupChange commitChange(
362 GroupSecretParams groupSecretParams,
363 int currentRevision,
364 GroupChange.Actions.Builder change,
365 GroupLinkPassword password
366 ) throws IOException {
367 final var nextRevision = currentRevision + 1;
368 final var changeActions = change.setRevision(nextRevision).build();
369
370 return groupsV2Api.patchGroup(changeActions,
371 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
372 Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
373 }
374
375 public DecryptedGroup getUpdatedDecryptedGroup(
376 DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
377 ) {
378 try {
379 final var decryptedGroupChange = getDecryptedGroupChange(signedGroupChange, groupMasterKey);
380 if (decryptedGroupChange == null) {
381 return null;
382 }
383 return DecryptedGroupUtil.apply(group, decryptedGroupChange);
384 } catch (NotAbleToApplyGroupV2ChangeException e) {
385 return null;
386 }
387 }
388
389 private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
390 if (signedGroupChange != null) {
391 var groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
392
393 try {
394 return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
395 } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
396 return null;
397 }
398 }
399
400 return null;
401 }
402 }