1 package org
.asamk
.signal
.manager
.storage
.groups
;
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
;
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
;
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
;
34 public class LegacyGroupStore
{
36 private final static Logger logger
= LoggerFactory
.getLogger(LegacyGroupStore
.class);
38 public static void migrate(
39 final Storage storage
,
40 final File groupCachePath
,
41 final RecipientResolver recipientResolver
,
42 final GroupStore groupStore
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
),
52 return recipientResolver
.resolveRecipient(m
.recipientId
);
53 }).filter(Objects
::nonNull
).collect(Collectors
.toSet());
55 return new GroupInfoV1(GroupIdV1
.fromBase64(g1
.groupId
),
56 g1
.expectedV2Id
== null ?
null : GroupIdV2
.fromBase64(g1
.expectedV2Id
),
60 g1
.messageExpirationTime
,
65 final var g2
= (Storage
.GroupV2
) g
;
66 var groupId
= GroupIdV2
.fromBase64(g2
.groupId
);
67 GroupMasterKey masterKey
;
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());
74 return new GroupInfoV2(groupId
,
76 loadDecryptedGroupLocked(groupId
, groupCachePath
),
77 g2
.distributionId
== null ? DistributionId
.create() : DistributionId
.from(g2
.distributionId
),
83 groupStore
.addLegacyGroups(groups
);
84 removeGroupCache(groupCachePath
);
87 private static void removeGroupCache(File groupCachePath
) {
88 final var files
= groupCachePath
.listFiles();
93 for (var file
: files
) {
95 Files
.delete(file
.toPath());
96 } catch (IOException e
) {
97 logger
.error("Failed to delete group cache file {}: {}", file
, e
.getMessage());
101 Files
.delete(groupCachePath
.toPath());
102 } catch (IOException e
) {
103 logger
.error("Failed to delete group cache directory {}: {}", groupCachePath
, e
.getMessage());
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
);
112 if (!groupFile
.exists()) {
115 try (var stream
= new FileInputStream(groupFile
)) {
116 return DecryptedGroup
.parseFrom(stream
);
117 } catch (IOException ignored
) {
122 private static File
getGroupV2FileLegacy(final GroupId groupId
, final File groupCachePath
) {
123 return new File(groupCachePath
, Hex
.toStringCondensed(groupId
.serialize()));
126 private static File
getGroupV2File(final GroupId groupId
, final File groupCachePath
) {
127 return new File(groupCachePath
, groupId
.toBase64().replace("/", "_"));
130 public record Storage(@JsonDeserialize(using
= GroupsDeserializer
.class) List
<Record
> groups
) {
132 public record GroupV1(
137 int messageExpirationTime
,
140 @JsonDeserialize(using
= MembersDeserializer
.class) List
<Member
> members
143 public record Member(Long recipientId
, String uuid
, String number
) {}
145 public record JsonRecipientAddress(String uuid
, String number
) {}
147 private static class MembersDeserializer
extends JsonDeserializer
<List
<Member
>> {
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
);
157 addresses
.add(new Member(null, null, n
.textValue()));
158 } else if (n
.isNumber()) {
159 addresses
.add(new Member(n
.numberValue().longValue(), null, null));
161 var address
= jsonParser
.getCodec().treeToValue(n
, JsonRecipientAddress
.class);
162 addresses
.add(new Member(null, address
.uuid
, address
.number
));
171 public record GroupV2(
174 String distributionId
,
175 @JsonInclude(JsonInclude
.Include
.NON_DEFAULT
) boolean blocked
,
176 @JsonInclude(JsonInclude
.Include
.NON_DEFAULT
) boolean permissionDenied
180 private static class GroupsDeserializer
extends JsonDeserializer
<List
<Object
>> {
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
);
190 if (n
.hasNonNull("masterKey")) {
192 g
= jsonParser
.getCodec().treeToValue(n
, Storage
.GroupV2
.class);
194 g
= jsonParser
.getCodec().treeToValue(n
, Storage
.GroupV1
.class);