]> nmode's Git Repositories - signal-cli/blob - src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
Implement accepting and declining group invitations
[signal-cli] / 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.storage.groups.GroupInfoV2;
6 import org.asamk.signal.util.IOUtils;
7 import org.signal.storageservice.protos.groups.GroupChange;
8 import org.signal.storageservice.protos.groups.Member;
9 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
10 import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
11 import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
12 import org.signal.zkgroup.InvalidInputException;
13 import org.signal.zkgroup.VerificationFailedException;
14 import org.signal.zkgroup.groups.GroupMasterKey;
15 import org.signal.zkgroup.groups.GroupSecretParams;
16 import org.signal.zkgroup.groups.UuidCiphertext;
17 import org.signal.zkgroup.profiles.ProfileKeyCredential;
18 import org.whispersystems.libsignal.util.Pair;
19 import org.whispersystems.libsignal.util.guava.Optional;
20 import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
21 import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
22 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
23 import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
24 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
25 import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
26 import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
27 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
28 import org.whispersystems.signalservice.api.util.UuidUtil;
29
30 import java.io.FileInputStream;
31 import java.io.IOException;
32 import java.io.InputStream;
33 import java.util.Collection;
34 import java.util.List;
35 import java.util.Set;
36 import java.util.UUID;
37 import java.util.stream.Collectors;
38
39 public class GroupHelper {
40
41 private final ProfileKeyCredentialProvider profileKeyCredentialProvider;
42
43 private final ProfileProvider profileProvider;
44
45 private final SelfAddressProvider selfAddressProvider;
46
47 private final GroupsV2Operations groupsV2Operations;
48
49 private final GroupsV2Api groupsV2Api;
50
51 private final GroupAuthorizationProvider groupAuthorizationProvider;
52
53 public GroupHelper(
54 final ProfileKeyCredentialProvider profileKeyCredentialProvider,
55 final ProfileProvider profileProvider,
56 final SelfAddressProvider selfAddressProvider,
57 final GroupsV2Operations groupsV2Operations,
58 final GroupsV2Api groupsV2Api,
59 final GroupAuthorizationProvider groupAuthorizationProvider
60 ) {
61 this.profileKeyCredentialProvider = profileKeyCredentialProvider;
62 this.profileProvider = profileProvider;
63 this.selfAddressProvider = selfAddressProvider;
64 this.groupsV2Operations = groupsV2Operations;
65 this.groupsV2Api = groupsV2Api;
66 this.groupAuthorizationProvider = groupAuthorizationProvider;
67 }
68
69 public GroupInfoV2 createGroupV2(
70 String name, Collection<SignalServiceAddress> members, String avatarFile
71 ) throws IOException {
72 final byte[] avatarBytes = readAvatarBytes(avatarFile);
73 final GroupsV2Operations.NewGroup newGroup = buildNewGroupV2(name, members, avatarBytes);
74 if (newGroup == null) {
75 return null;
76 }
77
78 final GroupSecretParams groupSecretParams = newGroup.getGroupSecretParams();
79
80 final GroupsV2AuthorizationString groupAuthForToday;
81 final DecryptedGroup decryptedGroup;
82 try {
83 groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams);
84 groupsV2Api.putNewGroup(newGroup, groupAuthForToday);
85 decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday);
86 } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
87 System.err.println("Failed to create V2 group: " + e.getMessage());
88 return null;
89 }
90 if (decryptedGroup == null) {
91 System.err.println("Failed to create V2 group!");
92 return null;
93 }
94
95 final byte[] groupId = groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
96 final GroupMasterKey masterKey = groupSecretParams.getMasterKey();
97 GroupInfoV2 g = new GroupInfoV2(groupId, masterKey);
98 g.setGroup(decryptedGroup);
99
100 return g;
101 }
102
103 private byte[] readAvatarBytes(final String avatarFile) throws IOException {
104 final byte[] avatarBytes;
105 try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
106 avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
107 }
108 return avatarBytes;
109 }
110
111 private GroupsV2Operations.NewGroup buildNewGroupV2(
112 String name, Collection<SignalServiceAddress> members, byte[] avatar
113 ) {
114 final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(
115 selfAddressProvider.getSelfAddress());
116 if (profileKeyCredential == null) {
117 System.err.println("Cannot create a V2 group as self does not have a versioned profile");
118 return null;
119 }
120
121 if (!areMembersValid(members)) return null;
122
123 GroupCandidate self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(),
124 Optional.fromNullable(profileKeyCredential));
125 Set<GroupCandidate> candidates = members.stream()
126 .map(member -> new GroupCandidate(member.getUuid().get(),
127 Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
128 .collect(Collectors.toSet());
129
130 final GroupSecretParams groupSecretParams = GroupSecretParams.generate();
131 return groupsV2Operations.createNewGroup(groupSecretParams,
132 name,
133 Optional.fromNullable(avatar),
134 self,
135 candidates,
136 Member.Role.DEFAULT,
137 0);
138 }
139
140 private boolean areMembersValid(final Collection<SignalServiceAddress> members) {
141 final int noUuidCapability = members.stream()
142 .filter(address -> !address.getUuid().isPresent())
143 .collect(Collectors.toUnmodifiableSet())
144 .size();
145 if (noUuidCapability > 0) {
146 System.err.println("Cannot create a V2 group as " + noUuidCapability + " members don't have a UUID.");
147 return false;
148 }
149
150 final int noGv2Capability = members.stream()
151 .map(profileProvider::getProfile)
152 .filter(profile -> profile != null && !profile.getCapabilities().gv2)
153 .collect(Collectors.toUnmodifiableSet())
154 .size();
155 if (noGv2Capability > 0) {
156 System.err.println("Cannot create a V2 group as " + noGv2Capability + " members don't support Groups V2.");
157 return false;
158 }
159
160 return true;
161 }
162
163 public Pair<DecryptedGroup, GroupChange> updateGroupV2(
164 GroupInfoV2 groupInfoV2, String name, String avatarFile
165 ) throws IOException {
166 final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
167 GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
168
169 GroupChange.Actions.Builder change = name != null
170 ? groupOperations.createModifyGroupTitle(name)
171 : GroupChange.Actions.newBuilder();
172
173 if (avatarFile != null) {
174 final byte[] avatarBytes = readAvatarBytes(avatarFile);
175 String avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes,
176 groupSecretParams,
177 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
178 change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
179 }
180
181 final Optional<UUID> uuid = this.selfAddressProvider.getSelfAddress().getUuid();
182 if (uuid.isPresent()) {
183 change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
184 }
185
186 return commitChange(groupInfoV2, change);
187 }
188
189 public Pair<DecryptedGroup, GroupChange> updateGroupV2(
190 GroupInfoV2 groupInfoV2, Set<SignalServiceAddress> newMembers
191 ) throws IOException {
192 final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
193 GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
194
195 if (!areMembersValid(newMembers)) return null;
196
197 Set<GroupCandidate> candidates = newMembers.stream()
198 .map(member -> new GroupCandidate(member.getUuid().get(),
199 Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
200 .collect(Collectors.toSet());
201
202 final GroupChange.Actions.Builder change = groupOperations.createModifyGroupMembershipChange(candidates,
203 selfAddressProvider.getSelfAddress().getUuid().get());
204
205 final Optional<UUID> uuid = this.selfAddressProvider.getSelfAddress().getUuid();
206 if (uuid.isPresent()) {
207 change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
208 }
209
210 return commitChange(groupInfoV2, change);
211 }
212
213 public Pair<DecryptedGroup, GroupChange> leaveGroup(GroupInfoV2 groupInfoV2) throws IOException {
214 List<DecryptedPendingMember> pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
215 final UUID selfUuid = selfAddressProvider.getSelfAddress().getUuid().get();
216 Optional<DecryptedPendingMember> selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList,
217 selfUuid);
218
219 if (selfPendingMember.isPresent()) {
220 return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
221 } else {
222 return ejectMembers(groupInfoV2, Set.of(selfUuid));
223 }
224 }
225
226 public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
227 final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
228 final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
229
230 final SignalServiceAddress selfAddress = this.selfAddressProvider.getSelfAddress();
231 final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(
232 selfAddress);
233 if (profileKeyCredential == null) {
234 throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
235 }
236
237 final GroupChange.Actions.Builder change = groupOperations.createAcceptInviteChange(profileKeyCredential);
238
239 final Optional<UUID> uuid = selfAddress.getUuid();
240 if (uuid.isPresent()) {
241 change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
242 }
243
244 return commitChange(groupInfoV2, change);
245 }
246
247 public Pair<DecryptedGroup, GroupChange> revokeInvites(
248 GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
249 ) throws IOException {
250 final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
251 final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
252 final Set<UuidCiphertext> uuidCipherTexts = pendingMembers.stream().map(member -> {
253 try {
254 return new UuidCiphertext(member.getUuidCipherText().toByteArray());
255 } catch (InvalidInputException e) {
256 throw new AssertionError(e);
257 }
258 }).collect(Collectors.toSet());
259 return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
260 }
261
262 public Pair<DecryptedGroup, GroupChange> ejectMembers(GroupInfoV2 groupInfoV2, Set<UUID> uuids) throws IOException {
263 final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
264 final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
265 return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
266 }
267
268 private Pair<DecryptedGroup, GroupChange> commitChange(
269 GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
270 ) throws IOException {
271 final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
272 final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
273 final DecryptedGroup previousGroupState = groupInfoV2.getGroup();
274 final int nextRevision = previousGroupState.getRevision() + 1;
275 final GroupChange.Actions changeActions = change.setRevision(nextRevision).build();
276 final DecryptedGroupChange decryptedChange;
277 final DecryptedGroup decryptedGroupState;
278
279 try {
280 decryptedChange = groupOperations.decryptChange(changeActions,
281 selfAddressProvider.getSelfAddress().getUuid().get());
282 decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
283 } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
284 throw new IOException(e);
285 }
286
287 GroupChange signedGroupChange = groupsV2Api.patchGroup(change.build(),
288 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
289 Optional.absent());
290
291 return new Pair<>(decryptedGroupState, signedGroupChange);
292 }
293
294 public DecryptedGroup getUpdatedDecryptedGroup(
295 DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
296 ) {
297 try {
298 final DecryptedGroupChange decryptedGroupChange = getDecryptedGroupChange(signedGroupChange,
299 groupMasterKey);
300 if (decryptedGroupChange == null) {
301 return null;
302 }
303 return DecryptedGroupUtil.apply(group, decryptedGroupChange);
304 } catch (NotAbleToApplyGroupV2ChangeException e) {
305 return null;
306 }
307 }
308
309 private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
310 if (signedGroupChange != null) {
311 GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(
312 groupMasterKey));
313
314 try {
315 return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
316 } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
317 return null;
318 }
319 }
320
321 return null;
322 }
323 }