]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/groups/LegacyGroupStore.java
1d8c8799bc5741e0b4ae9dfc3acb89f9ff0288d8
[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 static final 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.ACI.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 null);
64 }
65
66 final var g2 = (Storage.GroupV2) g;
67 var groupId = GroupIdV2.fromBase64(g2.groupId);
68 GroupMasterKey masterKey;
69 try {
70 masterKey = new GroupMasterKey(Base64.getDecoder().decode(g2.masterKey));
71 } catch (InvalidInputException | IllegalArgumentException e) {
72 throw new AssertionError("Invalid master key for group " + groupId.toBase64());
73 }
74
75 return new GroupInfoV2(groupId,
76 masterKey,
77 loadDecryptedGroupLocked(groupId, groupCachePath),
78 g2.distributionId == null ? DistributionId.create() : DistributionId.from(g2.distributionId),
79 g2.blocked,
80 true,
81 g2.permissionDenied,
82 null,
83 recipientResolver);
84 }).toList();
85
86 groupStore.addLegacyGroups(groups);
87 removeGroupCache(groupCachePath);
88 }
89
90 private static void removeGroupCache(File groupCachePath) {
91 final var files = groupCachePath.listFiles();
92 if (files == null) {
93 return;
94 }
95
96 for (var file : files) {
97 try {
98 Files.delete(file.toPath());
99 } catch (IOException e) {
100 logger.error("Failed to delete group cache file {}: {}", file, e.getMessage());
101 }
102 }
103 try {
104 Files.delete(groupCachePath.toPath());
105 } catch (IOException e) {
106 logger.error("Failed to delete group cache directory {}: {}", groupCachePath, e.getMessage());
107 }
108 }
109
110 private static DecryptedGroup loadDecryptedGroupLocked(final GroupIdV2 groupIdV2, final File groupCachePath) {
111 var groupFile = getGroupV2File(groupIdV2, groupCachePath);
112 if (!groupFile.exists()) {
113 groupFile = getGroupV2FileLegacy(groupIdV2, groupCachePath);
114 }
115 if (!groupFile.exists()) {
116 return null;
117 }
118 try (var stream = new FileInputStream(groupFile)) {
119 return DecryptedGroup.ADAPTER.decode(stream);
120 } catch (IOException ignored) {
121 return null;
122 }
123 }
124
125 private static File getGroupV2FileLegacy(final GroupId groupId, final File groupCachePath) {
126 return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
127 }
128
129 private static File getGroupV2File(final GroupId groupId, final File groupCachePath) {
130 return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
131 }
132
133 public record Storage(@JsonDeserialize(using = GroupsDeserializer.class) List<Record> groups) {
134
135 public record GroupV1(
136 String groupId,
137 String expectedV2Id,
138 String name,
139 String color,
140 int messageExpirationTime,
141 boolean blocked,
142 boolean archived,
143 @JsonDeserialize(using = MembersDeserializer.class) List<Member> members
144 ) {
145
146 public record Member(Long recipientId, String uuid, String number) {}
147
148 public record JsonRecipientAddress(String uuid, String number) {}
149
150 private static class MembersDeserializer extends JsonDeserializer<List<Member>> {
151
152 @Override
153 public List<Member> deserialize(
154 JsonParser jsonParser, DeserializationContext deserializationContext
155 ) throws IOException {
156 var addresses = new ArrayList<Member>();
157 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
158 for (var n : node) {
159 if (n.isTextual()) {
160 addresses.add(new Member(null, null, n.textValue()));
161 } else if (n.isNumber()) {
162 addresses.add(new Member(n.numberValue().longValue(), null, null));
163 } else {
164 var address = jsonParser.getCodec().treeToValue(n, JsonRecipientAddress.class);
165 addresses.add(new Member(null, address.uuid, address.number));
166 }
167 }
168
169 return addresses;
170 }
171 }
172 }
173
174 public record GroupV2(
175 String groupId,
176 String masterKey,
177 String distributionId,
178 @JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean blocked,
179 @JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean permissionDenied
180 ) {}
181 }
182
183 private static class GroupsDeserializer extends JsonDeserializer<List<Object>> {
184
185 @Override
186 public List<Object> deserialize(
187 JsonParser jsonParser, DeserializationContext deserializationContext
188 ) throws IOException {
189 var groups = new ArrayList<>();
190 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
191 for (var n : node) {
192 Object g;
193 if (n.hasNonNull("masterKey")) {
194 // a v2 group
195 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV2.class);
196 } else {
197 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV1.class);
198 }
199 groups.add(g);
200 }
201
202 return groups;
203 }
204 }
205 }