]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/groups/LegacyGroupStore.java
f004ae69883fab5cdf5e597853b5ce088f117253
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / storage / groups / LegacyGroupStore.java
1 package org.asamk.signal.manager.storage.groups;
2
3 import com.fasterxml.jackson.annotation.JsonInclude;
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.annotation.JsonDeserialize;
9
10 import org.asamk.signal.manager.api.GroupId;
11 import org.asamk.signal.manager.api.GroupIdV1;
12 import org.asamk.signal.manager.api.GroupIdV2;
13 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
14 import org.asamk.signal.manager.storage.recipients.RecipientResolver;
15 import org.signal.libsignal.zkgroup.InvalidInputException;
16 import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
17 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
18 import org.slf4j.Logger;
19 import org.slf4j.LoggerFactory;
20 import org.whispersystems.signalservice.api.push.DistributionId;
21 import org.whispersystems.signalservice.api.push.ServiceId;
22 import org.whispersystems.signalservice.internal.util.Hex;
23
24 import java.io.File;
25 import java.io.FileInputStream;
26 import java.io.IOException;
27 import java.nio.file.Files;
28 import java.util.ArrayList;
29 import java.util.Base64;
30 import java.util.List;
31 import java.util.Objects;
32 import java.util.stream.Collectors;
33
34 public class LegacyGroupStore {
35
36 private final static Logger logger = LoggerFactory.getLogger(LegacyGroupStore.class);
37
38 public static void migrate(
39 final Storage storage,
40 final File groupCachePath,
41 final RecipientResolver recipientResolver,
42 final GroupStore groupStore
43 ) {
44 final var groups = storage.groups.stream().map(g -> {
45 if (g instanceof Storage.GroupV1 g1) {
46 final var members = g1.members.stream().map(m -> {
47 if (m.recipientId == null) {
48 return recipientResolver.resolveRecipient(new RecipientAddress(ServiceId.parseOrNull(m.uuid),
49 m.number));
50 }
51
52 return recipientResolver.resolveRecipient(m.recipientId);
53 }).filter(Objects::nonNull).collect(Collectors.toSet());
54
55 return new GroupInfoV1(GroupIdV1.fromBase64(g1.groupId),
56 g1.expectedV2Id == null ? null : GroupIdV2.fromBase64(g1.expectedV2Id),
57 g1.name,
58 members,
59 g1.color,
60 g1.messageExpirationTime,
61 g1.blocked,
62 g1.archived);
63 }
64
65 final var g2 = (Storage.GroupV2) g;
66 var groupId = GroupIdV2.fromBase64(g2.groupId);
67 GroupMasterKey masterKey;
68 try {
69 masterKey = new GroupMasterKey(Base64.getDecoder().decode(g2.masterKey));
70 } catch (InvalidInputException | IllegalArgumentException e) {
71 throw new AssertionError("Invalid master key for group " + groupId.toBase64());
72 }
73
74 return new GroupInfoV2(groupId,
75 masterKey,
76 loadDecryptedGroupLocked(groupId, groupCachePath),
77 g2.distributionId == null ? DistributionId.create() : DistributionId.from(g2.distributionId),
78 g2.blocked,
79 g2.permissionDenied,
80 recipientResolver);
81 }).toList();
82
83 groupStore.addLegacyGroups(groups);
84 removeGroupCache(groupCachePath);
85 }
86
87 private static void removeGroupCache(File groupCachePath) {
88 final var files = groupCachePath.listFiles();
89 if (files == null) {
90 return;
91 }
92
93 for (var file : files) {
94 try {
95 Files.delete(file.toPath());
96 } catch (IOException e) {
97 logger.error("Failed to delete group cache file {}: {}", file, e.getMessage());
98 }
99 }
100 try {
101 Files.delete(groupCachePath.toPath());
102 } catch (IOException e) {
103 logger.error("Failed to delete group cache directory {}: {}", groupCachePath, e.getMessage());
104 }
105 }
106
107 private static DecryptedGroup loadDecryptedGroupLocked(final GroupIdV2 groupIdV2, final File groupCachePath) {
108 var groupFile = getGroupV2File(groupIdV2, groupCachePath);
109 if (!groupFile.exists()) {
110 groupFile = getGroupV2FileLegacy(groupIdV2, groupCachePath);
111 }
112 if (!groupFile.exists()) {
113 return null;
114 }
115 try (var stream = new FileInputStream(groupFile)) {
116 return DecryptedGroup.parseFrom(stream);
117 } catch (IOException ignored) {
118 return null;
119 }
120 }
121
122 private static File getGroupV2FileLegacy(final GroupId groupId, final File groupCachePath) {
123 return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
124 }
125
126 private static File getGroupV2File(final GroupId groupId, final File groupCachePath) {
127 return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
128 }
129
130 public record Storage(@JsonDeserialize(using = GroupsDeserializer.class) List<Record> groups) {
131
132 public record GroupV1(
133 String groupId,
134 String expectedV2Id,
135 String name,
136 String color,
137 int messageExpirationTime,
138 boolean blocked,
139 boolean archived,
140 @JsonDeserialize(using = MembersDeserializer.class) List<Member> members
141 ) {
142
143 public record Member(Long recipientId, String uuid, String number) {}
144
145 public record JsonRecipientAddress(String uuid, String number) {}
146
147 private static class MembersDeserializer extends JsonDeserializer<List<Member>> {
148
149 @Override
150 public List<Member> deserialize(
151 JsonParser jsonParser, DeserializationContext deserializationContext
152 ) throws IOException {
153 var addresses = new ArrayList<Member>();
154 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
155 for (var n : node) {
156 if (n.isTextual()) {
157 addresses.add(new Member(null, null, n.textValue()));
158 } else if (n.isNumber()) {
159 addresses.add(new Member(n.numberValue().longValue(), null, null));
160 } else {
161 var address = jsonParser.getCodec().treeToValue(n, JsonRecipientAddress.class);
162 addresses.add(new Member(null, address.uuid, address.number));
163 }
164 }
165
166 return addresses;
167 }
168 }
169 }
170
171 public record GroupV2(
172 String groupId,
173 String masterKey,
174 String distributionId,
175 @JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean blocked,
176 @JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean permissionDenied
177 ) {}
178 }
179
180 private static class GroupsDeserializer extends JsonDeserializer<List<Object>> {
181
182 @Override
183 public List<Object> deserialize(
184 JsonParser jsonParser, DeserializationContext deserializationContext
185 ) throws IOException {
186 var groups = new ArrayList<>();
187 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
188 for (var n : node) {
189 Object g;
190 if (n.hasNonNull("masterKey")) {
191 // a v2 group
192 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV2.class);
193 } else {
194 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV1.class);
195 }
196 groups.add(g);
197 }
198
199 return groups;
200 }
201 }
202 }