]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
Implement add/remove admin privileges
[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.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 GroupV2Helper {
47
48 private final static Logger logger = LoggerFactory.getLogger(GroupV2Helper.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 GroupV2Helper(
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> createGroup(
104 String name, Set<RecipientId> members, File avatarFile
105 ) throws IOException {
106 final var avatarBytes = readAvatarBytes(avatarFile);
107 final var newGroup = buildNewGroup(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 buildNewGroup(
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> updateGroup(
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> addMembers(
228 GroupInfoV2 groupInfoV2, Set<RecipientId> newMembers
229 ) throws IOException {
230 GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
231
232 if (!areMembersValid(newMembers)) {
233 throw new IOException("Failed to update group");
234 }
235
236 var candidates = newMembers.stream()
237 .map(member -> new GroupCandidate(addressResolver.resolveSignalServiceAddress(member).getUuid().get(),
238 Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
239 .collect(Collectors.toSet());
240
241 final var uuid = addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
242 .getUuid()
243 .get();
244 final var change = groupOperations.createModifyGroupMembershipChange(candidates, uuid);
245
246 change.setSourceUuid(UuidUtil.toByteString(uuid));
247
248 return commitChange(groupInfoV2, change);
249 }
250
251 public Pair<DecryptedGroup, GroupChange> leaveGroup(GroupInfoV2 groupInfoV2) throws IOException {
252 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
253 final var selfUuid = addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
254 .getUuid()
255 .get();
256 var selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfUuid);
257
258 if (selfPendingMember.isPresent()) {
259 return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
260 } else {
261 return ejectMembers(groupInfoV2, Set.of(selfUuid));
262 }
263 }
264
265 public Pair<DecryptedGroup, GroupChange> removeMembers(
266 GroupInfoV2 groupInfoV2, Set<RecipientId> members
267 ) throws IOException {
268 final var memberUuids = members.stream()
269 .map(addressResolver::resolveSignalServiceAddress)
270 .map(SignalServiceAddress::getUuid)
271 .filter(Optional::isPresent)
272 .map(Optional::get)
273 .collect(Collectors.toSet());
274 return ejectMembers(groupInfoV2, memberUuids);
275 }
276
277 public Pair<DecryptedGroup, GroupChange> revokeInvitedMembers(
278 GroupInfoV2 groupInfoV2, Set<RecipientId> members
279 ) throws IOException {
280 var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
281 final var memberUuids = members.stream()
282 .map(addressResolver::resolveSignalServiceAddress)
283 .map(SignalServiceAddress::getUuid)
284 .filter(Optional::isPresent)
285 .map(Optional::get)
286 .map(uuid -> DecryptedGroupUtil.findPendingByUuid(pendingMembersList, uuid))
287 .filter(Optional::isPresent)
288 .map(Optional::get)
289 .collect(Collectors.toSet());
290 return revokeInvites(groupInfoV2, memberUuids);
291 }
292
293 public GroupChange joinGroup(
294 GroupMasterKey groupMasterKey,
295 GroupLinkPassword groupLinkPassword,
296 DecryptedGroupJoinInfo decryptedGroupJoinInfo
297 ) throws IOException {
298 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
299 final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
300
301 final var selfRecipientId = this.selfRecipientIdProvider.getSelfRecipientId();
302 final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
303 if (profileKeyCredential == null) {
304 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
305 }
306
307 var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
308 var change = requestToJoin
309 ? groupOperations.createGroupJoinRequest(profileKeyCredential)
310 : groupOperations.createGroupJoinDirect(profileKeyCredential);
311
312 change.setSourceUuid(UuidUtil.toByteString(addressResolver.resolveSignalServiceAddress(selfRecipientId)
313 .getUuid()
314 .get()));
315
316 return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
317 }
318
319 public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
320 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
321
322 final var selfRecipientId = this.selfRecipientIdProvider.getSelfRecipientId();
323 final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
324 if (profileKeyCredential == null) {
325 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
326 }
327
328 final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
329
330 final var uuid = addressResolver.resolveSignalServiceAddress(selfRecipientId).getUuid();
331 if (uuid.isPresent()) {
332 change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
333 }
334
335 return commitChange(groupInfoV2, change);
336 }
337
338 public Pair<DecryptedGroup, GroupChange> setMemberAdmin(
339 GroupInfoV2 groupInfoV2, RecipientId recipientId, boolean admin
340 ) throws IOException {
341 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
342 final var address = addressResolver.resolveSignalServiceAddress(recipientId);
343 final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
344 final var change = groupOperations.createChangeMemberRole(address.getUuid().get(), newRole);
345 return commitChange(groupInfoV2, change);
346 }
347
348 private GroupsV2Operations.GroupOperations getGroupOperations(final GroupInfoV2 groupInfoV2) {
349 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
350 return groupsV2Operations.forGroup(groupSecretParams);
351 }
352
353 private Pair<DecryptedGroup, GroupChange> revokeInvites(
354 GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
355 ) throws IOException {
356 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
357 final var uuidCipherTexts = pendingMembers.stream().map(member -> {
358 try {
359 return new UuidCiphertext(member.getUuidCipherText().toByteArray());
360 } catch (InvalidInputException e) {
361 throw new AssertionError(e);
362 }
363 }).collect(Collectors.toSet());
364 return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
365 }
366
367 private Pair<DecryptedGroup, GroupChange> ejectMembers(
368 GroupInfoV2 groupInfoV2, Set<UUID> uuids
369 ) throws IOException {
370 final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
371 return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
372 }
373
374 private Pair<DecryptedGroup, GroupChange> commitChange(
375 GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
376 ) throws IOException {
377 final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
378 final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
379 final var previousGroupState = groupInfoV2.getGroup();
380 final var nextRevision = previousGroupState.getRevision() + 1;
381 final var changeActions = change.setRevision(nextRevision).build();
382 final DecryptedGroupChange decryptedChange;
383 final DecryptedGroup decryptedGroupState;
384
385 try {
386 decryptedChange = groupOperations.decryptChange(changeActions,
387 addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
388 .getUuid()
389 .get());
390 decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
391 } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
392 throw new IOException(e);
393 }
394
395 var signedGroupChange = groupsV2Api.patchGroup(changeActions,
396 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
397 Optional.absent());
398
399 return new Pair<>(decryptedGroupState, signedGroupChange);
400 }
401
402 private GroupChange commitChange(
403 GroupSecretParams groupSecretParams,
404 int currentRevision,
405 GroupChange.Actions.Builder change,
406 GroupLinkPassword password
407 ) throws IOException {
408 final var nextRevision = currentRevision + 1;
409 final var changeActions = change.setRevision(nextRevision).build();
410
411 return groupsV2Api.patchGroup(changeActions,
412 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
413 Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
414 }
415
416 public DecryptedGroup getUpdatedDecryptedGroup(
417 DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
418 ) {
419 try {
420 final var decryptedGroupChange = getDecryptedGroupChange(signedGroupChange, groupMasterKey);
421 if (decryptedGroupChange == null) {
422 return null;
423 }
424 return DecryptedGroupUtil.apply(group, decryptedGroupChange);
425 } catch (NotAbleToApplyGroupV2ChangeException e) {
426 return null;
427 }
428 }
429
430 private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
431 if (signedGroupChange != null) {
432 var groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
433
434 try {
435 return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
436 } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
437 return null;
438 }
439 }
440
441 return null;
442 }
443 }