Move group store to database

This commit is contained in:
AsamK 2022-06-08 23:29:30 +02:00
parent 46adc1af98
commit 65c9a2e185
9 changed files with 659 additions and 399 deletions

View file

@ -1031,45 +1031,40 @@
"allDeclaredConstructors":true "allDeclaredConstructors":true
}, },
{ {
"name":"org.asamk.signal.manager.storage.groups.GroupStore$GroupsDeserializer", "name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$GroupsDeserializer",
"methods":[{"name":"<init>","parameterTypes":[] }] "methods":[{"name":"<init>","parameterTypes":[] }]
}, },
{ {
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage", "name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage",
"allDeclaredFields":true,
"allDeclaredMethods":true,
"allDeclaredConstructors":true
},
{
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1",
"allDeclaredFields":true,
"allDeclaredMethods":true,
"allDeclaredConstructors":true
},
{
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1$JsonRecipientAddress",
"allDeclaredFields":true, "allDeclaredFields":true,
"queryAllDeclaredMethods":true, "queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true, "queryAllDeclaredConstructors":true,
"methods":[ "methods":[{"name":"<init>","parameterTypes":["java.util.List"] }]
{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String"] },
{"name":"number","parameterTypes":[] },
{"name":"uuid","parameterTypes":[] }
]
}, },
{ {
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1$MembersDeserializer", "name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV1",
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1$MembersSerializer",
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV2",
"allDeclaredFields":true, "allDeclaredFields":true,
"allDeclaredMethods":true, "queryAllDeclaredMethods":true,
"allDeclaredConstructors":true "queryAllDeclaredConstructors":true,
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","java.lang.String","int","boolean","boolean","java.util.List"] }]
},
{
"name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV1$JsonRecipientAddress",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String"] }]
},
{
"name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV1$MembersDeserializer",
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{
"name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV2",
"allDeclaredFields":true,
"queryAllDeclaredMethods":true,
"queryAllDeclaredConstructors":true,
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","boolean","boolean"] }]
}, },
{ {
"name":"org.asamk.signal.manager.storage.identities.IdentityKeyStore$IdentityStorage", "name":"org.asamk.signal.manager.storage.identities.IdentityKeyStore$IdentityStorage",
@ -1223,13 +1218,6 @@
"queryAllDeclaredConstructors":true, "queryAllDeclaredConstructors":true,
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","boolean"] }] "methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","boolean"] }]
}, },
{
"name":"org.asamk.signal.manager.storage.stickers.StickerStore",
"allDeclaredFields":true,
"allDeclaredMethods":true,
"allDeclaredConstructors":true,
"fields":[{"name":"stickers", "allowWrite":true}]
},
{ {
"name":"org.asamk.signal.util.SecurityProvider$DefaultRandom", "name":"org.asamk.signal.util.SecurityProvider$DefaultRandom",
"methods":[{"name":"<init>","parameterTypes":[] }] "methods":[{"name":"<init>","parameterTypes":[] }]

View file

@ -111,15 +111,17 @@ public class GroupHelper {
final GroupInfoV2 groupInfoV2; final GroupInfoV2 groupInfoV2;
if (groupInfo instanceof GroupInfoV1) { if (groupInfo instanceof GroupInfoV1) {
// Received a v2 group message for a v1 group, we need to locally migrate the group // Received a v2 group message for a v1 group, we need to locally migrate the group
account.getGroupStore().deleteGroupV1(((GroupInfoV1) groupInfo).getGroupId()); account.getGroupStore().deleteGroup(((GroupInfoV1) groupInfo).getGroupId());
groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey); groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, account.getRecipientResolver());
groupInfoV2.setBlocked(groupInfo.isBlocked());
account.getGroupStore().updateGroup(groupInfoV2);
logger.info("Locally migrated group {} to group v2, id: {}", logger.info("Locally migrated group {} to group v2, id: {}",
groupInfo.getGroupId().toBase64(), groupInfo.getGroupId().toBase64(),
groupInfoV2.getGroupId().toBase64()); groupInfoV2.getGroupId().toBase64());
} else if (groupInfo instanceof GroupInfoV2) { } else if (groupInfo instanceof GroupInfoV2) {
groupInfoV2 = (GroupInfoV2) groupInfo; groupInfoV2 = (GroupInfoV2) groupInfo;
} else { } else {
groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey); groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, account.getRecipientResolver());
} }
if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < revision) { if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < revision) {
@ -153,7 +155,7 @@ public class GroupHelper {
downloadGroupAvatar(groupId, groupSecretParams, avatar); downloadGroupAvatar(groupId, groupSecretParams, avatar);
} }
} }
groupInfoV2.setGroup(group, account.getRecipientResolver()); groupInfoV2.setGroup(group);
account.getGroupStore().updateGroup(groupInfoV2); account.getGroupStore().updateGroup(groupInfoV2);
} }
@ -183,7 +185,7 @@ public class GroupHelper {
final var gv2 = gv2Pair.first(); final var gv2 = gv2Pair.first();
final var decryptedGroup = gv2Pair.second(); final var decryptedGroup = gv2Pair.second();
gv2.setGroup(decryptedGroup, account.getRecipientResolver()); gv2.setGroup(decryptedGroup);
if (avatarFile != null) { if (avatarFile != null) {
context.getAvatarStore() context.getAvatarStore()
.storeGroupAvatar(gv2.getGroupId(), .storeGroupAvatar(gv2.getGroupId(),
@ -398,7 +400,7 @@ public class GroupHelper {
downloadGroupAvatar(groupInfoV2.getGroupId(), groupSecretParams, avatar); downloadGroupAvatar(groupInfoV2.getGroupId(), groupSecretParams, avatar);
} }
} }
groupInfoV2.setGroup(decryptedGroup, account.getRecipientResolver()); groupInfoV2.setGroup(decryptedGroup);
account.getGroupStore().updateGroup(group); account.getGroupStore().updateGroup(group);
} }
} }
@ -729,7 +731,7 @@ public class GroupHelper {
throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle()); throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
} }
final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins); final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins);
groupInfoV2.setGroup(groupGroupChangePair.first(), account.getRecipientResolver()); groupInfoV2.setGroup(groupGroupChangePair.first());
account.getGroupStore().updateGroup(groupInfoV2); account.getGroupStore().updateGroup(groupInfoV2);
var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray()); var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
@ -773,7 +775,7 @@ public class GroupHelper {
) throws IOException { ) throws IOException {
final var selfRecipientId = account.getSelfRecipientId(); final var selfRecipientId = account.getSelfRecipientId();
final var members = group.getMembersIncludingPendingWithout(selfRecipientId); final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
group.setGroup(newDecryptedGroup, account.getRecipientResolver()); group.setGroup(newDecryptedGroup);
members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId)); members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
account.getGroupStore().updateGroup(group); account.getGroupStore().updateGroup(group);

View file

@ -161,7 +161,7 @@ class GroupV2Helper {
final var groupId = GroupUtils.getGroupIdV2(groupSecretParams); final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
final var masterKey = groupSecretParams.getMasterKey(); final var masterKey = groupSecretParams.getMasterKey();
var g = new GroupInfoV2(groupId, masterKey); var g = new GroupInfoV2(groupId, masterKey, context.getAccount().getRecipientResolver());
return new Pair<>(g, decryptedGroup); return new Pair<>(g, decryptedGroup);
} }

View file

@ -2,6 +2,7 @@ package org.asamk.signal.manager.storage;
import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.HikariDataSource;
import org.asamk.signal.manager.storage.groups.GroupStore;
import org.asamk.signal.manager.storage.prekeys.PreKeyStore; import org.asamk.signal.manager.storage.prekeys.PreKeyStore;
import org.asamk.signal.manager.storage.prekeys.SignedPreKeyStore; import org.asamk.signal.manager.storage.prekeys.SignedPreKeyStore;
import org.asamk.signal.manager.storage.recipients.RecipientStore; import org.asamk.signal.manager.storage.recipients.RecipientStore;
@ -17,7 +18,7 @@ import java.sql.SQLException;
public class AccountDatabase extends Database { public class AccountDatabase extends Database {
private final static Logger logger = LoggerFactory.getLogger(AccountDatabase.class); private final static Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
private static final long DATABASE_VERSION = 4; private static final long DATABASE_VERSION = 5;
private AccountDatabase(final HikariDataSource dataSource) { private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, dataSource); super(logger, DATABASE_VERSION, dataSource);
@ -34,6 +35,7 @@ public class AccountDatabase extends Database {
StickerStore.createSql(connection); StickerStore.createSql(connection);
PreKeyStore.createSql(connection); PreKeyStore.createSql(connection);
SignedPreKeyStore.createSql(connection); SignedPreKeyStore.createSql(connection);
GroupStore.createSql(connection);
} }
@Override @Override
@ -109,5 +111,37 @@ public class AccountDatabase extends Database {
"""); """);
} }
} }
if (oldVersion < 5) {
logger.debug("Updating database: Creating group tables");
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
CREATE TABLE group_v2 (
_id INTEGER PRIMARY KEY,
group_id BLOB UNIQUE NOT NULL,
master_key BLOB NOT NULL,
group_data BLOB,
distribution_id BLOB UNIQUE NOT NULL,
blocked BOOLEAN NOT NULL DEFAULT FALSE,
permission_denied BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE group_v1 (
_id INTEGER PRIMARY KEY,
group_id BLOB UNIQUE NOT NULL,
group_id_v2 BLOB UNIQUE,
name TEXT,
color TEXT,
expiration_time INTEGER NOT NULL DEFAULT 0,
blocked BOOLEAN NOT NULL DEFAULT FALSE,
archived BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE group_v1_member (
_id INTEGER PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES group_v1 (_id) ON DELETE CASCADE,
recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
UNIQUE(group_id, recipient_id)
);
""");
}
}
} }
} }

View file

@ -12,8 +12,8 @@ import org.asamk.signal.manager.storage.configuration.ConfigurationStore;
import org.asamk.signal.manager.storage.contacts.ContactsStore; import org.asamk.signal.manager.storage.contacts.ContactsStore;
import org.asamk.signal.manager.storage.contacts.LegacyJsonContactsStore; import org.asamk.signal.manager.storage.contacts.LegacyJsonContactsStore;
import org.asamk.signal.manager.storage.groups.GroupInfoV1; import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.groups.GroupStore; import org.asamk.signal.manager.storage.groups.GroupStore;
import org.asamk.signal.manager.storage.groups.LegacyGroupStore;
import org.asamk.signal.manager.storage.identities.IdentityKeyStore; import org.asamk.signal.manager.storage.identities.IdentityKeyStore;
import org.asamk.signal.manager.storage.identities.SignalIdentityKeyStore; import org.asamk.signal.manager.storage.identities.SignalIdentityKeyStore;
import org.asamk.signal.manager.storage.identities.TrustNewIdentity; import org.asamk.signal.manager.storage.identities.TrustNewIdentity;
@ -61,7 +61,6 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.kbs.MasterKey; import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.api.push.ACI;
import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.push.PNI; import org.whispersystems.signalservice.api.push.PNI;
import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
@ -147,7 +146,6 @@ public class SignalAccount implements Closeable {
private SignalIdentityKeyStore aciIdentityKeyStore; private SignalIdentityKeyStore aciIdentityKeyStore;
private SenderKeyStore senderKeyStore; private SenderKeyStore senderKeyStore;
private GroupStore groupStore; private GroupStore groupStore;
private GroupStore.Storage groupStoreStorage;
private RecipientStore recipientStore; private RecipientStore recipientStore;
private StickerStore stickerStore; private StickerStore stickerStore;
private ConfigurationStore configurationStore; private ConfigurationStore configurationStore;
@ -216,9 +214,6 @@ public class SignalAccount implements Closeable {
signalAccount.localRegistrationId = registrationId; signalAccount.localRegistrationId = registrationId;
signalAccount.localPniRegistrationId = pniRegistrationId; signalAccount.localPniRegistrationId = pniRegistrationId;
signalAccount.trustNewIdentity = trustNewIdentity; signalAccount.trustNewIdentity = trustNewIdentity;
signalAccount.groupStore = new GroupStore(getGroupCachePath(dataPath, accountPath),
signalAccount.getRecipientResolver(),
signalAccount::saveGroupStore);
signalAccount.configurationStore = new ConfigurationStore(signalAccount::saveConfigurationStore); signalAccount.configurationStore = new ConfigurationStore(signalAccount::saveConfigurationStore);
signalAccount.registered = false; signalAccount.registered = false;
@ -340,9 +335,6 @@ public class SignalAccount implements Closeable {
pniIdentityKey, pniIdentityKey,
profileKey); profileKey);
signalAccount.groupStore = new GroupStore(getGroupCachePath(dataPath, accountPath),
signalAccount.getRecipientResolver(),
signalAccount::saveGroupStore);
signalAccount.configurationStore = new ConfigurationStore(signalAccount::saveConfigurationStore); signalAccount.configurationStore = new ConfigurationStore(signalAccount::saveConfigurationStore);
signalAccount.getRecipientTrustedResolver() signalAccount.getRecipientTrustedResolver()
@ -394,15 +386,6 @@ public class SignalAccount implements Closeable {
// Old config file, creating new profile key // Old config file, creating new profile key
setProfileKey(KeyUtils.createProfileKey()); setProfileKey(KeyUtils.createProfileKey());
} }
if (previousStorageVersion < 3) {
for (final var group : groupStore.getGroups()) {
if (group instanceof GroupInfoV2 && group.getDistributionId() == null) {
((GroupInfoV2) group).setDistributionId(DistributionId.create());
groupStore.updateGroup(group);
}
}
save();
}
if (isPrimaryDevice() && getPniIdentityKeyPair() == null) { if (isPrimaryDevice() && getPniIdentityKeyPair() == null) {
setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair()); setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
} }
@ -668,15 +651,13 @@ public class SignalAccount implements Closeable {
migratedLegacyConfig = loadLegacyStores(rootNode, legacySignalProtocolStore) || migratedLegacyConfig; migratedLegacyConfig = loadLegacyStores(rootNode, legacySignalProtocolStore) || migratedLegacyConfig;
if (rootNode.hasNonNull("groupStore")) { if (rootNode.hasNonNull("groupStore")) {
groupStoreStorage = jsonProcessor.convertValue(rootNode.get("groupStore"), GroupStore.Storage.class); final var groupStoreStorage = jsonProcessor.convertValue(rootNode.get("groupStore"),
groupStore = GroupStore.fromStorage(groupStoreStorage, LegacyGroupStore.Storage.class);
LegacyGroupStore.migrate(groupStoreStorage,
getGroupCachePath(dataPath, accountPath), getGroupCachePath(dataPath, accountPath),
getRecipientResolver(), getRecipientResolver(),
this::saveGroupStore); getGroupStore());
} else { migratedLegacyConfig = true;
groupStore = new GroupStore(getGroupCachePath(dataPath, accountPath),
getRecipientResolver(),
this::saveGroupStore);
} }
if (rootNode.hasNonNull("stickerStore")) { if (rootNode.hasNonNull("stickerStore")) {
@ -858,10 +839,10 @@ public class SignalAccount implements Closeable {
.build()); .build());
} }
} else { } else {
var groupInfo = groupStore.getGroup(GroupId.fromBase64(thread.id)); var groupInfo = getGroupStore().getGroup(GroupId.fromBase64(thread.id));
if (groupInfo instanceof GroupInfoV1) { if (groupInfo instanceof GroupInfoV1) {
((GroupInfoV1) groupInfo).messageExpirationTime = thread.messageExpirationTime; ((GroupInfoV1) groupInfo).messageExpirationTime = thread.messageExpirationTime;
groupStore.updateGroup(groupInfo); getGroupStore().updateGroup(groupInfo);
} }
} }
} catch (Exception e) { } catch (Exception e) {
@ -874,11 +855,6 @@ public class SignalAccount implements Closeable {
return false; return false;
} }
private void saveGroupStore(GroupStore.Storage storage) {
this.groupStoreStorage = storage;
save();
}
private void saveConfigurationStore(ConfigurationStore.Storage storage) { private void saveConfigurationStore(ConfigurationStore.Storage storage) {
this.configurationStoreStorage = storage; this.configurationStoreStorage = storage;
save(); save();
@ -925,7 +901,6 @@ public class SignalAccount implements Closeable {
.put("profileKey", .put("profileKey",
profileKey == null ? null : Base64.getEncoder().encodeToString(profileKey.serialize())) profileKey == null ? null : Base64.getEncoder().encodeToString(profileKey.serialize()))
.put("registered", registered) .put("registered", registered)
.putPOJO("groupStore", groupStoreStorage)
.putPOJO("configurationStore", configurationStoreStorage); .putPOJO("configurationStore", configurationStoreStorage);
try { try {
try (var output = new ByteArrayOutputStream()) { try (var output = new ByteArrayOutputStream()) {
@ -1111,7 +1086,10 @@ public class SignalAccount implements Closeable {
} }
public GroupStore getGroupStore() { public GroupStore getGroupStore() {
return groupStore; return getOrCreate(() -> groupStore,
() -> groupStore = new GroupStore(getAccountDatabase(),
getRecipientResolver(),
getRecipientIdCreator()));
} }
public ContactsStore getContactStore() { public ContactsStore getContactStore() {

View file

@ -43,7 +43,7 @@ public final class GroupInfoV1 extends GroupInfo {
this.groupId = groupId; this.groupId = groupId;
this.expectedV2Id = expectedV2Id; this.expectedV2Id = expectedV2Id;
this.name = name; this.name = name;
this.members = members; this.members = new HashSet<>(members);
this.color = color; this.color = color;
this.messageExpirationTime = messageExpirationTime; this.messageExpirationTime = messageExpirationTime;
this.blocked = blocked; this.blocked = blocked;
@ -78,7 +78,7 @@ public final class GroupInfoV1 extends GroupInfo {
} }
public Set<RecipientId> getMembers() { public Set<RecipientId> getMembers() {
return members; return new HashSet<>(members);
} }
@Override @Override

View file

@ -22,29 +22,36 @@ public final class GroupInfoV2 extends GroupInfo {
private final GroupMasterKey masterKey; private final GroupMasterKey masterKey;
private DistributionId distributionId; private DistributionId distributionId;
private boolean blocked; private boolean blocked;
private DecryptedGroup group; // stored as a file with base64 groupId as name private DecryptedGroup group;
private boolean permissionDenied; private boolean permissionDenied;
private RecipientResolver recipientResolver; private final RecipientResolver recipientResolver;
public GroupInfoV2(final GroupIdV2 groupId, final GroupMasterKey masterKey) { public GroupInfoV2(
final GroupIdV2 groupId, final GroupMasterKey masterKey, final RecipientResolver recipientResolver
) {
this.groupId = groupId; this.groupId = groupId;
this.masterKey = masterKey; this.masterKey = masterKey;
this.distributionId = DistributionId.create(); this.distributionId = DistributionId.create();
this.recipientResolver = recipientResolver;
} }
public GroupInfoV2( public GroupInfoV2(
final GroupIdV2 groupId, final GroupIdV2 groupId,
final GroupMasterKey masterKey, final GroupMasterKey masterKey,
final DecryptedGroup group,
final DistributionId distributionId, final DistributionId distributionId,
final boolean blocked, final boolean blocked,
final boolean permissionDenied final boolean permissionDenied,
final RecipientResolver recipientResolver
) { ) {
this.groupId = groupId; this.groupId = groupId;
this.masterKey = masterKey; this.masterKey = masterKey;
this.group = group;
this.distributionId = distributionId; this.distributionId = distributionId;
this.blocked = blocked; this.blocked = blocked;
this.permissionDenied = permissionDenied; this.permissionDenied = permissionDenied;
this.recipientResolver = recipientResolver;
} }
@Override @Override
@ -60,16 +67,11 @@ public final class GroupInfoV2 extends GroupInfo {
return distributionId; return distributionId;
} }
public void setDistributionId(final DistributionId distributionId) { public void setGroup(final DecryptedGroup group) {
this.distributionId = distributionId;
}
public void setGroup(final DecryptedGroup group, final RecipientResolver recipientResolver) {
if (group != null) { if (group != null) {
this.permissionDenied = false; this.permissionDenied = false;
} }
this.group = group; this.group = group;
this.recipientResolver = recipientResolver;
} }
public DecryptedGroup getGroup() { public DecryptedGroup getGroup() {

View file

@ -1,24 +1,16 @@
package org.asamk.signal.manager.storage.groups; package org.asamk.signal.manager.storage.groups;
import com.fasterxml.jackson.annotation.JsonInclude; import com.google.protobuf.InvalidProtocolBufferException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.asamk.signal.manager.groups.GroupId; import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.groups.GroupIdV1; import org.asamk.signal.manager.groups.GroupIdV1;
import org.asamk.signal.manager.groups.GroupIdV2; import org.asamk.signal.manager.groups.GroupIdV2;
import org.asamk.signal.manager.groups.GroupUtils; import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.Database;
import org.asamk.signal.manager.storage.Utils;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientIdCreator;
import org.asamk.signal.manager.storage.recipients.RecipientResolver; import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.IOUtils;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroup;
@ -26,359 +18,421 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.util.Hex;
import java.io.File; import java.sql.Connection;
import java.io.FileInputStream; import java.sql.ResultSet;
import java.io.FileOutputStream; import java.sql.SQLException;
import java.io.IOException; import java.sql.Types;
import java.nio.file.Files; import java.util.Arrays;
import java.util.ArrayList; import java.util.Collection;
import java.util.Base64;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream;
public class GroupStore { public class GroupStore {
private final static Logger logger = LoggerFactory.getLogger(GroupStore.class); private final static Logger logger = LoggerFactory.getLogger(GroupStore.class);
private static final String TABLE_GROUP_V2 = "group_v2";
private static final String TABLE_GROUP_V1 = "group_v1";
private static final String TABLE_GROUP_V1_MEMBER = "group_v1_member";
private final File groupCachePath; private final Database database;
private final Map<GroupId, GroupInfo> groups;
private final RecipientResolver recipientResolver; private final RecipientResolver recipientResolver;
private final Saver saver; private final RecipientIdCreator recipientIdCreator;
private GroupStore( public static void createSql(Connection connection) throws SQLException {
final File groupCachePath, // When modifying the CREATE statement here, also add a migration in AccountDatabase.java
final Map<GroupId, GroupInfo> groups, try (final var statement = connection.createStatement()) {
final RecipientResolver recipientResolver, statement.executeUpdate("""
final Saver saver CREATE TABLE group_v2 (
) { _id INTEGER PRIMARY KEY,
this.groupCachePath = groupCachePath; group_id BLOB UNIQUE NOT NULL,
this.groups = groups; master_key BLOB NOT NULL,
this.recipientResolver = recipientResolver; group_data BLOB,
this.saver = saver; distribution_id BLOB UNIQUE NOT NULL,
blocked BOOLEAN NOT NULL DEFAULT FALSE,
permission_denied BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE group_v1 (
_id INTEGER PRIMARY KEY,
group_id BLOB UNIQUE NOT NULL,
group_id_v2 BLOB UNIQUE,
name TEXT,
color TEXT,
expiration_time INTEGER NOT NULL DEFAULT 0,
blocked BOOLEAN NOT NULL DEFAULT FALSE,
archived BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE group_v1_member (
_id INTEGER PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES group_v1 (_id) ON DELETE CASCADE,
recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
UNIQUE(group_id, recipient_id)
);
""");
}
} }
public GroupStore( public GroupStore(
final File groupCachePath, final RecipientResolver recipientResolver, final Saver saver final Database database,
) {
this.groups = new HashMap<>();
this.groupCachePath = groupCachePath;
this.recipientResolver = recipientResolver;
this.saver = saver;
}
public static GroupStore fromStorage(
final Storage storage,
final File groupCachePath,
final RecipientResolver recipientResolver, final RecipientResolver recipientResolver,
final Saver saver final RecipientIdCreator recipientIdCreator
) { ) {
final var groups = storage.groups.stream().map(g -> { this.database = database;
if (g instanceof Storage.GroupV1 g1) { this.recipientResolver = recipientResolver;
final var members = g1.members.stream().map(m -> { this.recipientIdCreator = recipientIdCreator;
if (m.recipientId == null) {
return recipientResolver.resolveRecipient(new RecipientAddress(UuidUtil.parseOrNull(m.uuid),
m.number));
}
return recipientResolver.resolveRecipient(m.recipientId);
}).filter(Objects::nonNull).collect(Collectors.toSet());
return new GroupInfoV1(GroupIdV1.fromBase64(g1.groupId),
g1.expectedV2Id == null ? null : GroupIdV2.fromBase64(g1.expectedV2Id),
g1.name,
members,
g1.color,
g1.messageExpirationTime,
g1.blocked,
g1.archived);
}
final var g2 = (Storage.GroupV2) g;
var groupId = GroupIdV2.fromBase64(g2.groupId);
GroupMasterKey masterKey;
try {
masterKey = new GroupMasterKey(Base64.getDecoder().decode(g2.masterKey));
} catch (InvalidInputException | IllegalArgumentException e) {
throw new AssertionError("Invalid master key for group " + groupId.toBase64());
}
return new GroupInfoV2(groupId,
masterKey,
g2.distributionId == null ? null : DistributionId.from(g2.distributionId),
g2.blocked,
g2.permissionDenied);
}).collect(Collectors.toMap(GroupInfo::getGroupId, g -> g));
return new GroupStore(groupCachePath, groups, recipientResolver, saver);
} }
public void updateGroup(GroupInfo group) { public void updateGroup(GroupInfo group) {
final Storage storage; try (final var connection = database.getConnection()) {
synchronized (groups) { connection.setAutoCommit(false);
groups.put(group.getGroupId(), group); final Long internalId;
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() != null) { final var sql = (
try { """
IOUtils.createPrivateDirectories(groupCachePath); SELECT g._id
try (var stream = new FileOutputStream(getGroupV2File(group.getGroupId()))) { FROM %s g
((GroupInfoV2) group).getGroup().writeTo(stream); WHERE g.group_id = ?
"""
).formatted(group instanceof GroupInfoV1 ? TABLE_GROUP_V1 : TABLE_GROUP_V2);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, group.getGroupId().serialize());
internalId = Utils.executeQueryForOptional(statement, res -> res.getLong("_id")).orElse(null);
} }
final var groupFileLegacy = getGroupV2FileLegacy(group.getGroupId()); insertOrReplaceGroup(connection, internalId, group);
if (groupFileLegacy.exists()) { connection.commit();
try { } catch (SQLException e) {
Files.delete(groupFileLegacy.toPath()); throw new RuntimeException("Failed update recipient store", e);
} catch (IOException e) {
logger.error("Failed to delete legacy group file {}: {}", groupFileLegacy, e.getMessage());
} }
} }
} catch (IOException e) {
logger.warn("Failed to cache group, ignoring: {}", e.getMessage());
}
}
storage = toStorageLocked();
}
saver.save(storage);
}
public void deleteGroupV1(GroupIdV1 groupIdV1) {
deleteGroup(groupIdV1);
}
public void deleteGroup(GroupId groupId) { public void deleteGroup(GroupId groupId) {
final Storage storage; if (groupId instanceof GroupIdV1 groupIdV1) {
synchronized (groups) { deleteGroup(groupIdV1);
groups.remove(groupId); } else if (groupId instanceof GroupIdV2 groupIdV2) {
storage = toStorageLocked(); deleteGroup(groupIdV2);
}
}
public void deleteGroup(GroupIdV1 groupIdV1) {
final var sql = (
"""
DELETE FROM %s
WHERE group_id = ?
"""
).formatted(TABLE_GROUP_V1);
try (final var connection = database.getConnection()) {
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, groupIdV1.serialize());
statement.executeUpdate();
}
} catch (SQLException e) {
throw new RuntimeException("Failed update group store", e);
}
}
public void deleteGroup(GroupIdV2 groupIdV2) {
final var sql = (
"""
DELETE FROM %s
WHERE group_id = ?
"""
).formatted(TABLE_GROUP_V2);
try (final var connection = database.getConnection()) {
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, groupIdV2.serialize());
statement.executeUpdate();
}
} catch (SQLException e) {
throw new RuntimeException("Failed update group store", e);
} }
saver.save(storage);
} }
public GroupInfo getGroup(GroupId groupId) { public GroupInfo getGroup(GroupId groupId) {
synchronized (groups) { try (final var connection = database.getConnection()) {
return getGroupLocked(groupId); if (groupId instanceof GroupIdV1 groupIdV1) {
final var group = getGroup(connection, groupIdV1);
if (group != null) {
return group;
} }
return getGroupV2ByV1Id(connection, groupIdV1);
} else if (groupId instanceof GroupIdV2 groupIdV2) {
final var group = getGroup(connection, groupIdV2);
if (group != null) {
return group;
}
return getGroupV1ByV2Id(connection, groupIdV2);
}
} catch (SQLException e) {
throw new RuntimeException("Failed read from group store", e);
}
throw new AssertionError("Invalid group id type");
} }
public GroupInfoV1 getOrCreateGroupV1(GroupIdV1 groupId) { public GroupInfoV1 getOrCreateGroupV1(GroupIdV1 groupId) {
synchronized (groups) { try (final var connection = database.getConnection()) {
var group = getGroupLocked(groupId); var group = getGroup(connection, groupId);
if (group instanceof GroupInfoV1) {
return (GroupInfoV1) group; if (group != null) {
return group;
} }
if (group == null) { if (getGroupV2ByV1Id(connection, groupId) == null) {
return new GroupInfoV1(groupId); return new GroupInfoV1(groupId);
} }
return null; return null;
} catch (SQLException e) {
throw new RuntimeException("Failed read from group store", e);
} }
} }
public List<GroupInfo> getGroups() { public List<GroupInfo> getGroups() {
synchronized (groups) { return Stream.concat(getGroupsV2().stream(), getGroupsV1().stream()).toList();
final var groups = this.groups.values();
for (var group : groups) {
loadDecryptedGroupLocked(group);
}
return new ArrayList<>(groups);
}
} }
public void mergeRecipients(final RecipientId recipientId, final RecipientId toBeMergedRecipientId) { public void mergeRecipients(final RecipientId recipientId, final RecipientId toBeMergedRecipientId) {
Storage storage = null; final var sql = (
synchronized (groups) { """
var modified = false; UPDATE OR REPLACE %s
for (var group : this.groups.values()) { SET recipient_id = ?
WHERE recipient_id = ?
"""
).formatted(TABLE_GROUP_V1_MEMBER);
try (final var connection = database.getConnection()) {
try (final var statement = connection.prepareStatement(sql)) {
statement.setLong(1, recipientId.id());
statement.setLong(2, toBeMergedRecipientId.id());
final var updatedRows = statement.executeUpdate();
if (updatedRows > 0) {
logger.info("Updated {} group members when merging recipients", updatedRows);
}
}
} catch (SQLException e) {
throw new RuntimeException("Failed update group store", e);
}
}
void addLegacyGroups(final Collection<GroupInfo> groups) {
logger.debug("Migrating legacy groups to database");
long start = System.nanoTime();
try (final var connection = database.getConnection()) {
connection.setAutoCommit(false);
for (final var group : groups) {
insertOrReplaceGroup(connection, null, group);
}
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update group store", e);
}
logger.debug("Complete groups migration took {}ms", (System.nanoTime() - start) / 1000000);
}
private void insertOrReplaceGroup(
final Connection connection, Long internalId, final GroupInfo group
) throws SQLException {
if (group instanceof GroupInfoV1 groupV1) { if (group instanceof GroupInfoV1 groupV1) {
if (groupV1.isMember(toBeMergedRecipientId)) { if (internalId != null) {
groupV1.removeMember(toBeMergedRecipientId); final var sqlDeleteMembers = "DELETE FROM %s where group_id = ?".formatted(TABLE_GROUP_V1_MEMBER);
groupV1.addMembers(List.of(recipientId)); try (final var statement = connection.prepareStatement(sqlDeleteMembers)) {
modified = true; statement.setLong(1, internalId);
statement.executeUpdate();
}
}
final var sql = """
INSERT OR REPLACE INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, archived)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".formatted(TABLE_GROUP_V1);
try (final var statement = connection.prepareStatement(sql)) {
if (internalId == null) {
statement.setNull(1, Types.NUMERIC);
} else {
statement.setLong(1, internalId);
}
statement.setBytes(2, groupV1.getGroupId().serialize());
statement.setBytes(3, groupV1.getExpectedV2Id().serialize());
statement.setString(4, groupV1.getTitle());
statement.setString(5, groupV1.color);
statement.setLong(6, groupV1.getMessageExpirationTimer());
statement.setBoolean(7, groupV1.isBlocked());
statement.setBoolean(8, groupV1.archived);
statement.executeUpdate();
if (internalId == null) {
final var generatedKeys = statement.getGeneratedKeys();
if (generatedKeys.next()) {
internalId = generatedKeys.getLong(1);
} else {
throw new RuntimeException("Failed to add new recipient to database");
} }
} }
} }
if (modified) { final var sqlInsertMember = """
storage = toStorageLocked(); INSERT OR REPLACE INTO %s (group_id, recipient_id)
VALUES (?, ?)
""".formatted(TABLE_GROUP_V1_MEMBER);
try (final var statement = connection.prepareStatement(sqlInsertMember)) {
for (final var recipient : groupV1.getMembers()) {
statement.setLong(1, internalId);
statement.setLong(2, recipient.id());
statement.executeUpdate();
} }
} }
if (storage != null) { } else if (group instanceof GroupInfoV2 groupV2) {
saver.save(storage); final var sql = (
"""
INSERT OR REPLACE INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, distribution_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
"""
).formatted(TABLE_GROUP_V2);
try (final var statement = connection.prepareStatement(sql)) {
if (internalId == null) {
statement.setNull(1, Types.NUMERIC);
} else {
statement.setLong(1, internalId);
}
statement.setBytes(2, groupV2.getGroupId().serialize());
statement.setBytes(3, groupV2.getMasterKey().serialize());
if (groupV2.getGroup() == null) {
statement.setNull(4, Types.NUMERIC);
} else {
statement.setBytes(4, groupV2.getGroup().toByteArray());
}
statement.setBytes(5, UuidUtil.toByteArray(groupV2.getDistributionId().asUuid()));
statement.setBoolean(6, groupV2.isBlocked());
statement.setBoolean(7, groupV2.isPermissionDenied());
statement.executeUpdate();
}
} else {
throw new AssertionError("Invalid group id type");
} }
} }
private GroupInfo getGroupLocked(final GroupId groupId) { private List<GroupInfoV2> getGroupsV2() {
var group = groups.get(groupId); final var sql = (
if (group == null) { """
if (groupId instanceof GroupIdV1) { SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.permission_denied
group = getGroupByV1IdLocked((GroupIdV1) groupId); FROM %s g
} else if (groupId instanceof GroupIdV2) { """
group = getGroupV1ByV2IdLocked((GroupIdV2) groupId); ).formatted(TABLE_GROUP_V2);
try (final var connection = database.getConnection()) {
try (final var statement = connection.prepareStatement(sql)) {
return Utils.executeQueryForStream(statement, this::getGroupInfoV2FromResultSet)
.filter(Objects::nonNull)
.toList();
} }
} catch (SQLException e) {
throw new RuntimeException("Failed read from group store", e);
} }
loadDecryptedGroupLocked(group);
return group;
} }
private GroupInfo getGroupByV1IdLocked(final GroupIdV1 groupId) { private GroupInfoV2 getGroup(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
return groups.get(GroupUtils.getGroupIdV2(groupId)); final var sql = (
"""
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.permission_denied
FROM %s g
WHERE g.group_id = ?
"""
).formatted(TABLE_GROUP_V2);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, groupIdV2.serialize());
return Utils.executeQueryForOptional(statement, this::getGroupInfoV2FromResultSet).orElse(null);
}
} }
private GroupInfoV1 getGroupV1ByV2IdLocked(GroupIdV2 groupIdV2) { private GroupInfoV2 getGroupInfoV2FromResultSet(ResultSet resultSet) throws SQLException {
for (var g : groups.values()) { try {
if (g instanceof GroupInfoV1 gv1) { final var groupId = resultSet.getBytes("group_id");
if (groupIdV2.equals(gv1.getExpectedV2Id())) { final var masterKey = resultSet.getBytes("master_key");
return gv1; final var groupData = resultSet.getBytes("group_data");
} final var distributionId = resultSet.getBytes("distribution_id");
} final var blocked = resultSet.getBoolean("blocked");
} final var permissionDenied = resultSet.getBoolean("permission_denied");
return new GroupInfoV2(GroupId.v2(groupId),
new GroupMasterKey(masterKey),
groupData == null ? null : DecryptedGroup.parseFrom(groupData),
DistributionId.from(UuidUtil.parseOrThrow(distributionId)),
blocked,
permissionDenied,
recipientResolver);
} catch (InvalidInputException | InvalidProtocolBufferException e) {
return null; return null;
} }
}
private void loadDecryptedGroupLocked(final GroupInfo group) { private List<GroupInfoV1> getGroupsV1() {
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) { final var sql = (
var groupFile = getGroupV2File(group.getGroupId()); """
if (!groupFile.exists()) { SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived
groupFile = getGroupV2FileLegacy(group.getGroupId()); FROM %s g
} """
if (!groupFile.exists()) { ).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
return; try (final var connection = database.getConnection()) {
} try (final var statement = connection.prepareStatement(sql)) {
try (var stream = new FileInputStream(groupFile)) { return Utils.executeQueryForStream(statement, this::getGroupInfoV1FromResultSet)
((GroupInfoV2) group).setGroup(DecryptedGroup.parseFrom(stream), recipientResolver); .filter(Objects::nonNull)
} catch (IOException ignored) { .toList();
} }
} catch (SQLException e) {
throw new RuntimeException("Failed read from group store", e);
} }
} }
private File getGroupV2FileLegacy(final GroupId groupId) { private GroupInfoV1 getGroup(Connection connection, GroupIdV1 groupIdV1) throws SQLException {
return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize())); final var sql = (
} """
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived
private File getGroupV2File(final GroupId groupId) { FROM %s g
return new File(groupCachePath, groupId.toBase64().replace("/", "_")); WHERE g.group_id = ?
} """
).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
private Storage toStorageLocked() { try (final var statement = connection.prepareStatement(sql)) {
return new Storage(groups.values().stream().map(g -> { statement.setBytes(1, groupIdV1.serialize());
if (g instanceof GroupInfoV1 g1) { return Utils.executeQueryForOptional(statement, this::getGroupInfoV1FromResultSet).orElse(null);
return new Storage.GroupV1(g1.getGroupId().toBase64(),
g1.getExpectedV2Id().toBase64(),
g1.name,
g1.color,
g1.messageExpirationTime,
g1.blocked,
g1.archived,
g1.members.stream().map(m -> new Storage.GroupV1.Member(m.id(), null, null)).toList());
}
final var g2 = (GroupInfoV2) g;
return new Storage.GroupV2(g2.getGroupId().toBase64(),
Base64.getEncoder().encodeToString(g2.getMasterKey().serialize()),
g2.getDistributionId() == null ? null : g2.getDistributionId().toString(),
g2.isBlocked(),
g2.isPermissionDenied());
}).toList());
}
public record Storage(@JsonDeserialize(using = GroupsDeserializer.class) List<Record> groups) {
private record GroupV1(
String groupId,
String expectedV2Id,
String name,
String color,
int messageExpirationTime,
boolean blocked,
boolean archived,
@JsonDeserialize(using = MembersDeserializer.class) @JsonSerialize(using = MembersSerializer.class) List<Member> members
) {
private record Member(Long recipientId, String uuid, String number) {}
private record JsonRecipientAddress(String uuid, String number) {}
private static class MembersSerializer extends JsonSerializer<List<Member>> {
@Override
public void serialize(
final List<Member> value, final JsonGenerator jgen, final SerializerProvider provider
) throws IOException {
jgen.writeStartArray(null, value.size());
for (var address : value) {
if (address.recipientId != null) {
jgen.writeNumber(address.recipientId);
} else if (address.uuid != null) {
jgen.writeObject(new JsonRecipientAddress(address.uuid, address.number));
} else {
jgen.writeString(address.number);
}
}
jgen.writeEndArray();
} }
} }
private static class MembersDeserializer extends JsonDeserializer<List<Member>> { private GroupInfoV1 getGroupInfoV1FromResultSet(ResultSet resultSet) throws SQLException {
final var groupId = resultSet.getBytes("group_id");
@Override final var groupIdV2 = resultSet.getBytes("group_id_v2");
public List<Member> deserialize( final var name = resultSet.getString("name");
JsonParser jsonParser, DeserializationContext deserializationContext final var color = resultSet.getString("color");
) throws IOException { final var membersString = resultSet.getString("members");
var addresses = new ArrayList<Member>(); final var members = membersString == null
JsonNode node = jsonParser.getCodec().readTree(jsonParser); ? Set.<RecipientId>of()
for (var n : node) { : Arrays.stream(membersString.split(","))
if (n.isTextual()) { .map(Integer::valueOf)
addresses.add(new Member(null, null, n.textValue())); .map(recipientIdCreator::create)
} else if (n.isNumber()) { .collect(Collectors.toSet());
addresses.add(new Member(n.numberValue().longValue(), null, null)); final var expirationTime = resultSet.getInt("expiration_time");
} else { final var blocked = resultSet.getBoolean("blocked");
var address = jsonParser.getCodec().treeToValue(n, JsonRecipientAddress.class); final var archived = resultSet.getBoolean("archived");
addresses.add(new Member(null, address.uuid, address.number)); return new GroupInfoV1(GroupId.v1(groupId),
} groupIdV2 == null ? null : GroupId.v2(groupIdV2),
name,
members,
color,
expirationTime,
blocked,
archived);
} }
return addresses; private GroupInfoV2 getGroupV2ByV1Id(final Connection connection, final GroupIdV1 groupId) throws SQLException {
} return getGroup(connection, GroupUtils.getGroupIdV2(groupId));
}
} }
private record GroupV2( private GroupInfoV1 getGroupV1ByV2Id(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
String groupId, final var sql = (
String masterKey, """
String distributionId, SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived
@JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean blocked, FROM %s g
@JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean permissionDenied WHERE g.group_id_v2 = ?
) {} """
).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
try (final var statement = connection.prepareStatement(sql)) {
statement.setBytes(1, groupIdV2.serialize());
return Utils.executeQueryForOptional(statement, this::getGroupInfoV1FromResultSet).orElse(null);
} }
private static class GroupsDeserializer extends JsonDeserializer<List<Object>> {
@Override
public List<Object> deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
var groups = new ArrayList<>();
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
for (var n : node) {
Object g;
if (n.hasNonNull("masterKey")) {
// a v2 group
g = jsonParser.getCodec().treeToValue(n, Storage.GroupV2.class);
} else {
g = jsonParser.getCodec().treeToValue(n, Storage.GroupV1.class);
}
groups.add(g);
}
return groups;
}
}
public interface Saver {
void save(Storage storage);
} }
} }

View file

@ -0,0 +1,202 @@
package org.asamk.signal.manager.storage.groups;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import org.asamk.signal.manager.groups.GroupId;
import org.asamk.signal.manager.groups.GroupIdV1;
import org.asamk.signal.manager.groups.GroupIdV2;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.util.Hex;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public class LegacyGroupStore {
private final static Logger logger = LoggerFactory.getLogger(LegacyGroupStore.class);
public static void migrate(
final Storage storage,
final File groupCachePath,
final RecipientResolver recipientResolver,
final GroupStore groupStore
) {
final var groups = storage.groups.stream().map(g -> {
if (g instanceof Storage.GroupV1 g1) {
final var members = g1.members.stream().map(m -> {
if (m.recipientId == null) {
return recipientResolver.resolveRecipient(new RecipientAddress(UuidUtil.parseOrNull(m.uuid),
m.number));
}
return recipientResolver.resolveRecipient(m.recipientId);
}).filter(Objects::nonNull).collect(Collectors.toSet());
return new GroupInfoV1(GroupIdV1.fromBase64(g1.groupId),
g1.expectedV2Id == null ? null : GroupIdV2.fromBase64(g1.expectedV2Id),
g1.name,
members,
g1.color,
g1.messageExpirationTime,
g1.blocked,
g1.archived);
}
final var g2 = (Storage.GroupV2) g;
var groupId = GroupIdV2.fromBase64(g2.groupId);
GroupMasterKey masterKey;
try {
masterKey = new GroupMasterKey(Base64.getDecoder().decode(g2.masterKey));
} catch (InvalidInputException | IllegalArgumentException e) {
throw new AssertionError("Invalid master key for group " + groupId.toBase64());
}
return new GroupInfoV2(groupId,
masterKey,
loadDecryptedGroupLocked(groupId, groupCachePath),
g2.distributionId == null ? DistributionId.create() : DistributionId.from(g2.distributionId),
g2.blocked,
g2.permissionDenied,
recipientResolver);
}).toList();
groupStore.addLegacyGroups(groups);
removeGroupCache(groupCachePath);
}
private static void removeGroupCache(File groupCachePath) {
final var files = groupCachePath.listFiles();
if (files == null) {
return;
}
for (var file : files) {
try {
Files.delete(file.toPath());
} catch (IOException e) {
logger.error("Failed to delete group cache file {}: {}", file, e.getMessage());
}
}
try {
Files.delete(groupCachePath.toPath());
} catch (IOException e) {
logger.error("Failed to delete group cache directory {}: {}", groupCachePath, e.getMessage());
}
}
private static DecryptedGroup loadDecryptedGroupLocked(final GroupIdV2 groupIdV2, final File groupCachePath) {
var groupFile = getGroupV2File(groupIdV2, groupCachePath);
if (!groupFile.exists()) {
groupFile = getGroupV2FileLegacy(groupIdV2, groupCachePath);
}
if (!groupFile.exists()) {
return null;
}
try (var stream = new FileInputStream(groupFile)) {
return DecryptedGroup.parseFrom(stream);
} catch (IOException ignored) {
return null;
}
}
private static File getGroupV2FileLegacy(final GroupId groupId, final File groupCachePath) {
return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
}
private static File getGroupV2File(final GroupId groupId, final File groupCachePath) {
return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
}
public record Storage(@JsonDeserialize(using = GroupsDeserializer.class) List<Record> groups) {
private record GroupV1(
String groupId,
String expectedV2Id,
String name,
String color,
int messageExpirationTime,
boolean blocked,
boolean archived,
@JsonDeserialize(using = MembersDeserializer.class) List<Member> members
) {
private record Member(Long recipientId, String uuid, String number) {}
private record JsonRecipientAddress(String uuid, String number) {}
private static class MembersDeserializer extends JsonDeserializer<List<Member>> {
@Override
public List<Member> deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
var addresses = new ArrayList<Member>();
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
for (var n : node) {
if (n.isTextual()) {
addresses.add(new Member(null, null, n.textValue()));
} else if (n.isNumber()) {
addresses.add(new Member(n.numberValue().longValue(), null, null));
} else {
var address = jsonParser.getCodec().treeToValue(n, JsonRecipientAddress.class);
addresses.add(new Member(null, address.uuid, address.number));
}
}
return addresses;
}
}
}
private record GroupV2(
String groupId,
String masterKey,
String distributionId,
@JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean blocked,
@JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean permissionDenied
) {}
}
private static class GroupsDeserializer extends JsonDeserializer<List<Object>> {
@Override
public List<Object> deserialize(
JsonParser jsonParser, DeserializationContext deserializationContext
) throws IOException {
var groups = new ArrayList<>();
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
for (var n : node) {
Object g;
if (n.hasNonNull("masterKey")) {
// a v2 group
g = jsonParser.getCodec().treeToValue(n, Storage.GroupV2.class);
} else {
g = jsonParser.getCodec().treeToValue(n, Storage.GroupV1.class);
}
groups.add(g);
}
return groups;
}
}
}