]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java
b38d3902ceb8439415d5e2c05e29bce8cd9a119f
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / storage / groups / GroupStore.java
1 package org.asamk.signal.manager.storage.groups;
2
3 import com.fasterxml.jackson.core.JsonGenerator;
4 import com.fasterxml.jackson.core.JsonParser;
5 import com.fasterxml.jackson.databind.DeserializationContext;
6 import com.fasterxml.jackson.databind.JsonDeserializer;
7 import com.fasterxml.jackson.databind.JsonNode;
8 import com.fasterxml.jackson.databind.JsonSerializer;
9 import com.fasterxml.jackson.databind.SerializerProvider;
10 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
11 import com.fasterxml.jackson.databind.annotation.JsonSerialize;
12
13 import org.asamk.signal.manager.groups.GroupId;
14 import org.asamk.signal.manager.groups.GroupIdV1;
15 import org.asamk.signal.manager.groups.GroupIdV2;
16 import org.asamk.signal.manager.groups.GroupUtils;
17 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
18 import org.asamk.signal.manager.storage.recipients.RecipientId;
19 import org.asamk.signal.manager.storage.recipients.RecipientResolver;
20 import org.asamk.signal.manager.util.IOUtils;
21 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
22 import org.signal.zkgroup.InvalidInputException;
23 import org.signal.zkgroup.groups.GroupMasterKey;
24 import org.slf4j.Logger;
25 import org.slf4j.LoggerFactory;
26 import org.whispersystems.signalservice.api.util.UuidUtil;
27 import org.whispersystems.signalservice.internal.util.Hex;
28
29 import java.io.File;
30 import java.io.FileInputStream;
31 import java.io.FileOutputStream;
32 import java.io.IOException;
33 import java.nio.file.Files;
34 import java.util.ArrayList;
35 import java.util.Base64;
36 import java.util.HashMap;
37 import java.util.List;
38 import java.util.Map;
39 import java.util.stream.Collectors;
40
41 public class GroupStore {
42
43 private final static Logger logger = LoggerFactory.getLogger(GroupStore.class);
44
45 private final File groupCachePath;
46 private final Map<GroupId, GroupInfo> groups;
47 private final RecipientResolver recipientResolver;
48 private final Saver saver;
49
50 private GroupStore(
51 final File groupCachePath,
52 final Map<GroupId, GroupInfo> groups,
53 final RecipientResolver recipientResolver,
54 final Saver saver
55 ) {
56 this.groupCachePath = groupCachePath;
57 this.groups = groups;
58 this.recipientResolver = recipientResolver;
59 this.saver = saver;
60 }
61
62 public GroupStore(
63 final File groupCachePath, final RecipientResolver recipientResolver, final Saver saver
64 ) {
65 this.groups = new HashMap<>();
66 this.groupCachePath = groupCachePath;
67 this.recipientResolver = recipientResolver;
68 this.saver = saver;
69 }
70
71 public static GroupStore fromStorage(
72 final Storage storage,
73 final File groupCachePath,
74 final RecipientResolver recipientResolver,
75 final Saver saver
76 ) {
77 final var groups = storage.groups.stream().map(g -> {
78 if (g instanceof Storage.GroupV1) {
79 final var g1 = (Storage.GroupV1) g;
80 final var members = g1.members.stream().map(m -> {
81 if (m.recipientId == null) {
82 return recipientResolver.resolveRecipient(new RecipientAddress(UuidUtil.parseOrNull(m.uuid),
83 m.number));
84 }
85
86 return RecipientId.of(m.recipientId);
87 }).collect(Collectors.toSet());
88
89 return new GroupInfoV1(GroupIdV1.fromBase64(g1.groupId),
90 g1.expectedV2Id == null ? null : GroupIdV2.fromBase64(g1.expectedV2Id),
91 g1.name,
92 members,
93 g1.color,
94 g1.messageExpirationTime,
95 g1.blocked,
96 g1.archived);
97 }
98
99 final var g2 = (Storage.GroupV2) g;
100 var groupId = GroupIdV2.fromBase64(g2.groupId);
101 GroupMasterKey masterKey;
102 try {
103 masterKey = new GroupMasterKey(Base64.getDecoder().decode(g2.masterKey));
104 } catch (InvalidInputException | IllegalArgumentException e) {
105 throw new AssertionError("Invalid master key for group " + groupId.toBase64());
106 }
107
108 return new GroupInfoV2(groupId, masterKey, g2.blocked, g2.permissionDenied);
109 }).collect(Collectors.toMap(GroupInfo::getGroupId, g -> g));
110
111 return new GroupStore(groupCachePath, groups, recipientResolver, saver);
112 }
113
114 public void updateGroup(GroupInfo group) {
115 final Storage storage;
116 synchronized (groups) {
117 groups.put(group.getGroupId(), group);
118 if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() != null) {
119 try {
120 IOUtils.createPrivateDirectories(groupCachePath);
121 try (var stream = new FileOutputStream(getGroupV2File(group.getGroupId()))) {
122 ((GroupInfoV2) group).getGroup().writeTo(stream);
123 }
124 final var groupFileLegacy = getGroupV2FileLegacy(group.getGroupId());
125 if (groupFileLegacy.exists()) {
126 try {
127 Files.delete(groupFileLegacy.toPath());
128 } catch (IOException e) {
129 logger.error("Failed to delete legacy group file {}: {}", groupFileLegacy, e.getMessage());
130 }
131 }
132 } catch (IOException e) {
133 logger.warn("Failed to cache group, ignoring: {}", e.getMessage());
134 }
135 }
136 storage = toStorageLocked();
137 }
138 saver.save(storage);
139 }
140
141 public void deleteGroupV1(GroupIdV1 groupIdV1) {
142 deleteGroup(groupIdV1);
143 }
144
145 public void deleteGroup(GroupId groupId) {
146 final Storage storage;
147 synchronized (groups) {
148 groups.remove(groupId);
149 storage = toStorageLocked();
150 }
151 saver.save(storage);
152 }
153
154 public GroupInfo getGroup(GroupId groupId) {
155 synchronized (groups) {
156 return getGroupLocked(groupId);
157 }
158 }
159
160 public GroupInfoV1 getOrCreateGroupV1(GroupIdV1 groupId) {
161 synchronized (groups) {
162 var group = getGroupLocked(groupId);
163 if (group instanceof GroupInfoV1) {
164 return (GroupInfoV1) group;
165 }
166
167 if (group == null) {
168 return new GroupInfoV1(groupId);
169 }
170
171 return null;
172 }
173 }
174
175 public List<GroupInfo> getGroups() {
176 synchronized (groups) {
177 final var groups = this.groups.values();
178 for (var group : groups) {
179 loadDecryptedGroupLocked(group);
180 }
181 return new ArrayList<>(groups);
182 }
183 }
184
185 public void mergeRecipients(final RecipientId recipientId, final RecipientId toBeMergedRecipientId) {
186 synchronized (groups) {
187 var modified = false;
188 for (var group : this.groups.values()) {
189 if (group instanceof GroupInfoV1) {
190 var groupV1 = (GroupInfoV1) group;
191 if (groupV1.isMember(toBeMergedRecipientId)) {
192 groupV1.removeMember(toBeMergedRecipientId);
193 groupV1.addMembers(List.of(recipientId));
194 modified = true;
195 }
196 }
197 }
198 if (modified) {
199 saver.save(toStorageLocked());
200 }
201 }
202 }
203
204 private GroupInfo getGroupLocked(final GroupId groupId) {
205 var group = groups.get(groupId);
206 if (group == null) {
207 if (groupId instanceof GroupIdV1) {
208 group = getGroupByV1IdLocked((GroupIdV1) groupId);
209 } else if (groupId instanceof GroupIdV2) {
210 group = getGroupV1ByV2IdLocked((GroupIdV2) groupId);
211 }
212 }
213 loadDecryptedGroupLocked(group);
214 return group;
215 }
216
217 private GroupInfo getGroupByV1IdLocked(final GroupIdV1 groupId) {
218 return groups.get(GroupUtils.getGroupIdV2(groupId));
219 }
220
221 private GroupInfoV1 getGroupV1ByV2IdLocked(GroupIdV2 groupIdV2) {
222 for (var g : groups.values()) {
223 if (g instanceof GroupInfoV1) {
224 final var gv1 = (GroupInfoV1) g;
225 if (groupIdV2.equals(gv1.getExpectedV2Id())) {
226 return gv1;
227 }
228 }
229 }
230 return null;
231 }
232
233 private void loadDecryptedGroupLocked(final GroupInfo group) {
234 if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) {
235 var groupFile = getGroupV2File(group.getGroupId());
236 if (!groupFile.exists()) {
237 groupFile = getGroupV2FileLegacy(group.getGroupId());
238 }
239 if (!groupFile.exists()) {
240 return;
241 }
242 try (var stream = new FileInputStream(groupFile)) {
243 ((GroupInfoV2) group).setGroup(DecryptedGroup.parseFrom(stream), recipientResolver);
244 } catch (IOException ignored) {
245 }
246 }
247 }
248
249 private File getGroupV2FileLegacy(final GroupId groupId) {
250 return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
251 }
252
253 private File getGroupV2File(final GroupId groupId) {
254 return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
255 }
256
257 private Storage toStorageLocked() {
258 return new Storage(groups.values().stream().map(g -> {
259 if (g instanceof GroupInfoV1) {
260 final var g1 = (GroupInfoV1) g;
261 return new Storage.GroupV1(g1.getGroupId().toBase64(),
262 g1.getExpectedV2Id().toBase64(),
263 g1.name,
264 g1.color,
265 g1.messageExpirationTime,
266 g1.blocked,
267 g1.archived,
268 g1.members.stream()
269 .map(m -> new Storage.GroupV1.Member(m.getId(), null, null))
270 .collect(Collectors.toList()));
271 }
272
273 final var g2 = (GroupInfoV2) g;
274 return new Storage.GroupV2(g2.getGroupId().toBase64(),
275 Base64.getEncoder().encodeToString(g2.getMasterKey().serialize()),
276 g2.isBlocked(),
277 g2.isPermissionDenied());
278 }).collect(Collectors.toList()));
279 }
280
281 public static class Storage {
282
283 @JsonDeserialize(using = GroupsDeserializer.class)
284 public List<Storage.Group> groups;
285
286 // For deserialization
287 public Storage() {
288 }
289
290 public Storage(final List<Storage.Group> groups) {
291 this.groups = groups;
292 }
293
294 private abstract static class Group {
295
296 }
297
298 private static class GroupV1 extends Group {
299
300 public String groupId;
301 public String expectedV2Id;
302 public String name;
303 public String color;
304 public int messageExpirationTime;
305 public boolean blocked;
306 public boolean archived;
307
308 @JsonDeserialize(using = MembersDeserializer.class)
309 @JsonSerialize(using = MembersSerializer.class)
310 public List<Member> members;
311
312 // For deserialization
313 public GroupV1() {
314 }
315
316 public GroupV1(
317 final String groupId,
318 final String expectedV2Id,
319 final String name,
320 final String color,
321 final int messageExpirationTime,
322 final boolean blocked,
323 final boolean archived,
324 final List<Member> members
325 ) {
326 this.groupId = groupId;
327 this.expectedV2Id = expectedV2Id;
328 this.name = name;
329 this.color = color;
330 this.messageExpirationTime = messageExpirationTime;
331 this.blocked = blocked;
332 this.archived = archived;
333 this.members = members;
334 }
335
336 private static final class Member {
337
338 public Long recipientId;
339
340 public String uuid;
341
342 public String number;
343
344 Member(Long recipientId, final String uuid, final String number) {
345 this.recipientId = recipientId;
346 this.uuid = uuid;
347 this.number = number;
348 }
349 }
350
351 private static final class JsonRecipientAddress {
352
353 public String uuid;
354
355 public String number;
356
357 // For deserialization
358 public JsonRecipientAddress() {
359 }
360
361 JsonRecipientAddress(final String uuid, final String number) {
362 this.uuid = uuid;
363 this.number = number;
364 }
365 }
366
367 private static class MembersSerializer extends JsonSerializer<List<Member>> {
368
369 @Override
370 public void serialize(
371 final List<Member> value, final JsonGenerator jgen, final SerializerProvider provider
372 ) throws IOException {
373 jgen.writeStartArray(value.size());
374 for (var address : value) {
375 if (address.recipientId != null) {
376 jgen.writeNumber(address.recipientId);
377 } else if (address.uuid != null) {
378 jgen.writeObject(new JsonRecipientAddress(address.uuid, address.number));
379 } else {
380 jgen.writeString(address.number);
381 }
382 }
383 jgen.writeEndArray();
384 }
385 }
386
387 private static class MembersDeserializer extends JsonDeserializer<List<Member>> {
388
389 @Override
390 public List<Member> deserialize(
391 JsonParser jsonParser, DeserializationContext deserializationContext
392 ) throws IOException {
393 var addresses = new ArrayList<Member>();
394 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
395 for (var n : node) {
396 if (n.isTextual()) {
397 addresses.add(new Member(null, null, n.textValue()));
398 } else if (n.isNumber()) {
399 addresses.add(new Member(n.numberValue().longValue(), null, null));
400 } else {
401 var address = jsonParser.getCodec().treeToValue(n, JsonRecipientAddress.class);
402 addresses.add(new Member(null, address.uuid, address.number));
403 }
404 }
405
406 return addresses;
407 }
408 }
409 }
410
411 private static class GroupV2 extends Group {
412
413 public String groupId;
414 public String masterKey;
415 public boolean blocked;
416 public boolean permissionDenied;
417
418 // For deserialization
419 private GroupV2() {
420 }
421
422 public GroupV2(
423 final String groupId, final String masterKey, final boolean blocked, final boolean permissionDenied
424 ) {
425 this.groupId = groupId;
426 this.masterKey = masterKey;
427 this.blocked = blocked;
428 this.permissionDenied = permissionDenied;
429 }
430 }
431
432 }
433
434 private static class GroupsDeserializer extends JsonDeserializer<List<Storage.Group>> {
435
436 @Override
437 public List<Storage.Group> deserialize(
438 JsonParser jsonParser, DeserializationContext deserializationContext
439 ) throws IOException {
440 var groups = new ArrayList<Storage.Group>();
441 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
442 for (var n : node) {
443 Storage.Group g;
444 if (n.hasNonNull("masterKey")) {
445 // a v2 group
446 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV2.class);
447 } else {
448 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV1.class);
449 }
450 groups.add(g);
451 }
452
453 return groups;
454 }
455 }
456
457 public interface Saver {
458
459 void save(Storage storage);
460 }
461 }