From 0c4a037dde99f80d464ddaa2845363e9bf51712c Mon Sep 17 00:00:00 2001 From: AsamK Date: Fri, 10 Jun 2022 14:34:24 +0200 Subject: [PATCH] Move identity store to database --- graalvm-config-dir/reflect-config.json | 7 +- .../org/asamk/signal/manager/ManagerImpl.java | 2 +- .../asamk/signal/manager/api/Identity.java | 4 +- .../signal/manager/helper/SyncHelper.java | 2 +- .../manager/storage/AccountDatabase.java | 18 +- .../signal/manager/storage/SignalAccount.java | 12 +- .../storage/identities/IdentityInfo.java | 12 +- .../storage/identities/IdentityKeyStore.java | 308 ++++++++++-------- .../identities/LegacyIdentityKeyStore.java | 102 ++++++ .../identities/SignalIdentityKeyStore.java | 3 +- .../commands/ListIdentitiesCommand.java | 5 +- 11 files changed, 311 insertions(+), 164 deletions(-) create mode 100644 lib/src/main/java/org/asamk/signal/manager/storage/identities/LegacyIdentityKeyStore.java diff --git a/graalvm-config-dir/reflect-config.json b/graalvm-config-dir/reflect-config.json index 4942bda4..3bd9bf08 100644 --- a/graalvm-config-dir/reflect-config.json +++ b/graalvm-config-dir/reflect-config.json @@ -1067,10 +1067,11 @@ "methods":[{"name":"","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.LegacyIdentityKeyStore$IdentityStorage", "allDeclaredFields":true, - "allDeclaredMethods":true, - "allDeclaredConstructors":true + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":["java.lang.String","int","long"] }] }, { "name":"org.asamk.signal.manager.storage.profiles.LegacyProfileStore", diff --git a/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java index 54a6b917..6c52d59e 100644 --- a/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java @@ -1044,7 +1044,7 @@ class ManagerImpl implements Manager { .computeSafetyNumber(identityInfo.getRecipientId(), identityInfo.getIdentityKey()), scannableFingerprint == null ? null : scannableFingerprint.getSerialized(), identityInfo.getTrustLevel(), - identityInfo.getDateAdded()); + identityInfo.getDateAddedTimestamp()); } @Override diff --git a/lib/src/main/java/org/asamk/signal/manager/api/Identity.java b/lib/src/main/java/org/asamk/signal/manager/api/Identity.java index 8785df76..c4755576 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/Identity.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/Identity.java @@ -3,15 +3,13 @@ package org.asamk.signal.manager.api; import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.signal.libsignal.protocol.IdentityKey; -import java.util.Date; - public record Identity( RecipientAddress recipient, IdentityKey identityKey, String safetyNumber, byte[] scannableSafetyNumber, TrustLevel trustLevel, - Date dateAdded + long dateAddedTimestamp ) { public byte[] getFingerprint() { diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java index d192ae8f..73662d22 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java @@ -138,7 +138,7 @@ public class SyncHelper { verifiedMessage = new VerifiedMessage(address, currentIdentity.getIdentityKey(), currentIdentity.getTrustLevel().toVerifiedState(), - currentIdentity.getDateAdded().getTime()); + currentIdentity.getDateAddedTimestamp()); } var profileKey = account.getProfileStore().getProfileKey(recipientId); diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java index ef88c227..48b82ea2 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java @@ -3,6 +3,7 @@ package org.asamk.signal.manager.storage; import com.zaxxer.hikari.HikariDataSource; import org.asamk.signal.manager.storage.groups.GroupStore; +import org.asamk.signal.manager.storage.identities.IdentityKeyStore; import org.asamk.signal.manager.storage.prekeys.PreKeyStore; import org.asamk.signal.manager.storage.prekeys.SignedPreKeyStore; import org.asamk.signal.manager.storage.recipients.RecipientStore; @@ -19,7 +20,7 @@ import java.sql.SQLException; public class AccountDatabase extends Database { private final static Logger logger = LoggerFactory.getLogger(AccountDatabase.class); - private static final long DATABASE_VERSION = 6; + private static final long DATABASE_VERSION = 7; private AccountDatabase(final HikariDataSource dataSource) { super(logger, DATABASE_VERSION, dataSource); @@ -38,6 +39,7 @@ public class AccountDatabase extends Database { SignedPreKeyStore.createSql(connection); GroupStore.createSql(connection); SessionStore.createSql(connection); + IdentityKeyStore.createSql(connection); } @Override @@ -160,5 +162,19 @@ public class AccountDatabase extends Database { """); } } + if (oldVersion < 7) { + logger.debug("Updating database: Creating identity table"); + try (final var statement = connection.createStatement()) { + statement.executeUpdate(""" + CREATE TABLE identity ( + _id INTEGER PRIMARY KEY, + recipient_id INTEGER UNIQUE NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE, + identity_key BLOB NOT NULL, + added_timestamp INTEGER NOT NULL, + trust_level INTEGER NOT NULL + ); + """); + } + } } } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index cf98bffe..e09cda3e 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -15,6 +15,7 @@ import org.asamk.signal.manager.storage.groups.GroupInfoV1; 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.LegacyIdentityKeyStore; import org.asamk.signal.manager.storage.identities.SignalIdentityKeyStore; import org.asamk.signal.manager.storage.identities.TrustNewIdentity; import org.asamk.signal.manager.storage.messageCache.MessageCache; @@ -646,6 +647,11 @@ public class SignalAccount implements Closeable { LegacySessionStore.migrate(legacySessionsPath, getRecipientResolver(), getAciSessionStore()); migratedLegacyConfig = true; } + final var legacyIdentitiesPath = getIdentitiesPath(dataPath, accountPath); + if (legacyIdentitiesPath.exists()) { + LegacyIdentityKeyStore.migrate(legacyIdentitiesPath, getRecipientResolver(), getIdentityKeyStore()); + migratedLegacyConfig = true; + } final var legacySignalProtocolStore = rootNode.hasNonNull("axolotlStore") ? jsonProcessor.convertValue(Utils.getNotNullNode(rootNode, "axolotlStore"), LegacyJsonSignalProtocolStore.class) @@ -753,7 +759,7 @@ public class SignalAccount implements Closeable { logger.debug("Migrating legacy identity session store."); for (var identity : legacySignalProtocolStore.getLegacyIdentityKeyStore().getIdentities()) { RecipientId recipientId = getRecipientStore().resolveRecipientTrusted(identity.getAddress()); - getIdentityKeyStore().saveIdentity(recipientId, identity.getIdentityKey(), identity.getDateAdded()); + getIdentityKeyStore().saveIdentity(recipientId, identity.getIdentityKey()); getIdentityKeyStore().setIdentityTrustLevel(recipientId, identity.getIdentityKey(), identity.getTrustLevel()); @@ -1105,8 +1111,8 @@ public class SignalAccount implements Closeable { public IdentityKeyStore getIdentityKeyStore() { return getOrCreate(() -> identityKeyStore, - () -> identityKeyStore = new IdentityKeyStore(getIdentitiesPath(dataPath, accountPath), - getRecipientResolver(), + () -> identityKeyStore = new IdentityKeyStore(getAccountDatabase(), + getRecipientIdCreator(), trustNewIdentity)); } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityInfo.java b/lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityInfo.java index 5a7324e5..571f564d 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityInfo.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityInfo.java @@ -4,22 +4,20 @@ import org.asamk.signal.manager.api.TrustLevel; import org.asamk.signal.manager.storage.recipients.RecipientId; import org.signal.libsignal.protocol.IdentityKey; -import java.util.Date; - public class IdentityInfo { private final RecipientId recipientId; private final IdentityKey identityKey; private final TrustLevel trustLevel; - private final Date added; + private final long addedTimestamp; IdentityInfo( - final RecipientId recipientId, IdentityKey identityKey, TrustLevel trustLevel, Date added + final RecipientId recipientId, IdentityKey identityKey, TrustLevel trustLevel, long addedTimestamp ) { this.recipientId = recipientId; this.identityKey = identityKey; this.trustLevel = trustLevel; - this.added = added; + this.addedTimestamp = addedTimestamp; } public RecipientId getRecipientId() { @@ -38,7 +36,7 @@ public class IdentityInfo { return trustLevel == TrustLevel.TRUSTED_UNVERIFIED || trustLevel == TrustLevel.TRUSTED_VERIFIED; } - public Date getDateAdded() { - return this.added; + public long getDateAddedTimestamp() { + return this.addedTimestamp; } } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityKeyStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityKeyStore.java index 4d08f89f..3971532f 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityKeyStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityKeyStore.java @@ -1,90 +1,81 @@ package org.asamk.signal.manager.storage.identities; -import com.fasterxml.jackson.databind.ObjectMapper; - import org.asamk.signal.manager.api.TrustLevel; +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.RecipientResolver; -import org.asamk.signal.manager.util.IOUtils; +import org.asamk.signal.manager.storage.recipients.RecipientIdCreator; import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.state.IdentityKeyStore.Direction; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.util.Arrays; -import java.util.Base64; -import java.util.Date; -import java.util.HashMap; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collection; import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.regex.Pattern; +import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.subjects.PublishSubject; -import io.reactivex.rxjava3.subjects.Subject; public class IdentityKeyStore { private final static Logger logger = LoggerFactory.getLogger(IdentityKeyStore.class); - private final ObjectMapper objectMapper = org.asamk.signal.manager.storage.Utils.createStorageObjectMapper(); - - private final Map cachedIdentities = new HashMap<>(); - - private final File identitiesPath; - - private final RecipientResolver resolver; + private static final String TABLE_IDENTITY = "identity"; + private final Database database; + private final RecipientIdCreator recipientIdCreator; private final TrustNewIdentity trustNewIdentity; private final PublishSubject identityChanges = PublishSubject.create(); private boolean isRetryingDecryption = false; + public static void createSql(Connection connection) throws SQLException { + // When modifying the CREATE statement here, also add a migration in AccountDatabase.java + try (final var statement = connection.createStatement()) { + statement.executeUpdate(""" + CREATE TABLE identity ( + _id INTEGER PRIMARY KEY, + recipient_id INTEGER UNIQUE NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE, + identity_key BLOB NOT NULL, + added_timestamp INTEGER NOT NULL, + trust_level INTEGER NOT NULL + ); + """); + } + } + public IdentityKeyStore( - final File identitiesPath, final RecipientResolver resolver, final TrustNewIdentity trustNewIdentity + final Database database, + final RecipientIdCreator recipientIdCreator, + final TrustNewIdentity trustNewIdentity ) { - this.identitiesPath = identitiesPath; - this.resolver = resolver; + this.database = database; + this.recipientIdCreator = recipientIdCreator; this.trustNewIdentity = trustNewIdentity; } - public Subject getIdentityChanges() { + public Observable getIdentityChanges() { return identityChanges; } public boolean saveIdentity(final RecipientId recipientId, final IdentityKey identityKey) { - return saveIdentity(recipientId, identityKey, null); - } - - public boolean saveIdentity(final RecipientId recipientId, final IdentityKey identityKey, Date added) { if (isRetryingDecryption) { return false; } - synchronized (cachedIdentities) { - final var identityInfo = loadIdentityLocked(recipientId); + try (final var connection = database.getConnection()) { + final var identityInfo = loadIdentity(connection, recipientId); if (identityInfo != null && identityInfo.getIdentityKey().equals(identityKey)) { // Identity already exists, not updating the trust level logger.trace("Not storing new identity for recipient {}, identity already stored", recipientId); return false; } - final var trustLevel = trustNewIdentity == TrustNewIdentity.ALWAYS || ( - trustNewIdentity == TrustNewIdentity.ON_FIRST_USE && identityInfo == null - ) ? TrustLevel.TRUSTED_UNVERIFIED : TrustLevel.UNTRUSTED; - logger.debug("Storing new identity for recipient {} with trust {}", recipientId, trustLevel); - final var newIdentityInfo = new IdentityInfo(recipientId, - identityKey, - trustLevel, - added == null ? new Date() : added); - storeIdentityLocked(recipientId, newIdentityInfo); - identityChanges.onNext(recipientId); + saveNewIdentity(connection, recipientId, identityKey, identityInfo == null); return true; + } catch (SQLException e) { + throw new RuntimeException("Failed update identity store", e); } } @@ -93,8 +84,8 @@ public class IdentityKeyStore { } public boolean setIdentityTrustLevel(RecipientId recipientId, IdentityKey identityKey, TrustLevel trustLevel) { - synchronized (cachedIdentities) { - final var identityInfo = loadIdentityLocked(recipientId); + try (final var connection = database.getConnection()) { + final var identityInfo = loadIdentity(connection, recipientId); if (identityInfo == null) { logger.debug("Not updating trust level for recipient {}, identity not found", recipientId); return false; @@ -112,9 +103,11 @@ public class IdentityKeyStore { final var newIdentityInfo = new IdentityInfo(recipientId, identityKey, trustLevel, - identityInfo.getDateAdded()); - storeIdentityLocked(recipientId, newIdentityInfo); + identityInfo.getDateAddedTimestamp()); + storeIdentity(connection, newIdentityInfo); return true; + } catch (SQLException e) { + throw new RuntimeException("Failed update identity store", e); } } @@ -123,19 +116,19 @@ public class IdentityKeyStore { return true; } - synchronized (cachedIdentities) { + try (final var connection = database.getConnection()) { // TODO implement possibility for different handling of incoming/outgoing trust decisions - var identityInfo = loadIdentityLocked(recipientId); + var identityInfo = loadIdentity(connection, recipientId); if (identityInfo == null) { logger.debug("Initial identity found for {}, saving.", recipientId); - saveIdentity(recipientId, identityKey); - identityInfo = loadIdentityLocked(recipientId); + saveNewIdentity(connection, recipientId, identityKey, true); + identityInfo = loadIdentity(connection, recipientId); } else if (!identityInfo.getIdentityKey().equals(identityKey)) { // Identity found, but different if (direction == Direction.SENDING) { logger.debug("Changed identity found for {}, saving.", recipientId); - saveIdentity(recipientId, identityKey); - identityInfo = loadIdentityLocked(recipientId); + saveNewIdentity(connection, recipientId, identityKey, false); + identityInfo = loadIdentity(connection, recipientId); } else { logger.trace("Trusting identity for {} for {}: {}", recipientId, direction, false); return false; @@ -145,125 +138,156 @@ public class IdentityKeyStore { final var isTrusted = identityInfo != null && identityInfo.isTrusted(); logger.trace("Trusting identity for {} for {}: {}", recipientId, direction, isTrusted); return isTrusted; - } - } - - public IdentityKey getIdentity(RecipientId recipientId) { - synchronized (cachedIdentities) { - var identity = loadIdentityLocked(recipientId); - return identity == null ? null : identity.getIdentityKey(); + } catch (SQLException e) { + throw new RuntimeException("Failed read from identity store", e); } } public IdentityInfo getIdentityInfo(RecipientId recipientId) { - synchronized (cachedIdentities) { - return loadIdentityLocked(recipientId); + try (final var connection = database.getConnection()) { + return loadIdentity(connection, recipientId); + } catch (SQLException e) { + throw new RuntimeException("Failed read from identity store", e); } } - final Pattern identityFileNamePattern = Pattern.compile("(\\d+)"); - public List getIdentities() { - final var files = identitiesPath.listFiles(); - if (files == null) { - return List.of(); + try (final var connection = database.getConnection()) { + final var sql = ( + """ + SELECT i.recipient_id, i.identity_key, i.added_timestamp, i.trust_level + FROM %s AS i + """ + ).formatted(TABLE_IDENTITY); + try (final var statement = connection.prepareStatement(sql)) { + return Utils.executeQueryForStream(statement, this::getIdentityInfoFromResultSet).toList(); + } + } catch (SQLException e) { + throw new RuntimeException("Failed read from identity store", e); } - return Arrays.stream(files) - .filter(f -> identityFileNamePattern.matcher(f.getName()).matches()) - .map(f -> resolver.resolveRecipient(Long.parseLong(f.getName()))) - .filter(Objects::nonNull) - .map(this::loadIdentityLocked) - .filter(Objects::nonNull) - .toList(); } public void mergeRecipients(final RecipientId recipientId, final RecipientId toBeMergedRecipientId) { - synchronized (cachedIdentities) { - deleteIdentityLocked(toBeMergedRecipientId); - } - } + try (final var connection = database.getConnection()) { + connection.setAutoCommit(false); + final var sql = ( + """ + UPDATE OR IGNORE %s + SET recipient_id = ? + WHERE recipient_id = ? + """ + ).formatted(TABLE_IDENTITY); + try (final var statement = connection.prepareStatement(sql)) { + statement.setLong(1, recipientId.id()); + statement.setLong(2, toBeMergedRecipientId.id()); + statement.executeUpdate(); + } - public void deleteIdentity(final RecipientId recipientId) { - synchronized (cachedIdentities) { - deleteIdentityLocked(recipientId); + deleteIdentity(connection, toBeMergedRecipientId); + connection.commit(); + } catch (SQLException e) { + throw new RuntimeException("Failed update identity store", e); } } - private File getIdentityFile(final RecipientId recipientId) { - try { - IOUtils.createPrivateDirectories(identitiesPath); - } catch (IOException e) { - throw new AssertionError("Failed to create identities path", e); + public void deleteIdentity(final RecipientId recipientId) { + try (final var connection = database.getConnection()) { + deleteIdentity(connection, recipientId); + } catch (SQLException e) { + throw new RuntimeException("Failed update identity store", e); } - return new File(identitiesPath, String.valueOf(recipientId.id())); } - private IdentityInfo loadIdentityLocked(final RecipientId recipientId) { - { - final var session = cachedIdentities.get(recipientId); - if (session != null) { - return session; + void addLegacyIdentities(final Collection identities) { + logger.debug("Migrating legacy identities to database"); + long start = System.nanoTime(); + try (final var connection = database.getConnection()) { + connection.setAutoCommit(false); + for (final var identityInfo : identities) { + storeIdentity(connection, identityInfo); } + connection.commit(); + } catch (SQLException e) { + throw new RuntimeException("Failed update identity store", e); } + logger.debug("Complete identities migration took {}ms", (System.nanoTime() - start) / 1000000); + } - final var file = getIdentityFile(recipientId); - if (!file.exists()) { - return null; + private IdentityInfo loadIdentity( + final Connection connection, final RecipientId recipientId + ) throws SQLException { + final var sql = ( + """ + SELECT i.recipient_id, i.identity_key, i.added_timestamp, i.trust_level + FROM %s AS i + WHERE i.recipient_id = ? + """ + ).formatted(TABLE_IDENTITY); + try (final var statement = connection.prepareStatement(sql)) { + statement.setLong(1, recipientId.id()); + return Utils.executeQueryForOptional(statement, this::getIdentityInfoFromResultSet).orElse(null); } - try (var inputStream = new FileInputStream(file)) { - var storage = objectMapper.readValue(inputStream, IdentityStorage.class); - - var id = new IdentityKey(Base64.getDecoder().decode(storage.identityKey())); - var trustLevel = TrustLevel.fromInt(storage.trustLevel()); - var added = new Date(storage.addedTimestamp()); + } - final var identityInfo = new IdentityInfo(recipientId, id, trustLevel, added); - cachedIdentities.put(recipientId, identityInfo); - return identityInfo; - } catch (IOException | InvalidKeyException e) { - logger.warn("Failed to load identity key: {}", e.getMessage()); - return null; - } + private void saveNewIdentity( + final Connection connection, + final RecipientId recipientId, + final IdentityKey identityKey, + final boolean firstIdentity + ) throws SQLException { + final var trustLevel = trustNewIdentity == TrustNewIdentity.ALWAYS || ( + trustNewIdentity == TrustNewIdentity.ON_FIRST_USE && firstIdentity + ) ? TrustLevel.TRUSTED_UNVERIFIED : TrustLevel.UNTRUSTED; + logger.debug("Storing new identity for recipient {} with trust {}", recipientId, trustLevel); + final var newIdentityInfo = new IdentityInfo(recipientId, identityKey, trustLevel, System.currentTimeMillis()); + storeIdentity(connection, newIdentityInfo); + identityChanges.onNext(recipientId); } - private void storeIdentityLocked(final RecipientId recipientId, final IdentityInfo identityInfo) { + private void storeIdentity(final Connection connection, final IdentityInfo identityInfo) throws SQLException { logger.trace("Storing identity info for {}, trust: {}, added: {}", - recipientId, + identityInfo.getRecipientId(), identityInfo.getTrustLevel(), - identityInfo.getDateAdded()); - cachedIdentities.put(recipientId, identityInfo); - - var storage = new IdentityStorage(Base64.getEncoder().encodeToString(identityInfo.getIdentityKey().serialize()), - identityInfo.getTrustLevel().ordinal(), - identityInfo.getDateAdded().getTime()); - - final var file = getIdentityFile(recipientId); - // Write to memory first to prevent corrupting the file in case of serialization errors - try (var inMemoryOutput = new ByteArrayOutputStream()) { - objectMapper.writeValue(inMemoryOutput, storage); - - var input = new ByteArrayInputStream(inMemoryOutput.toByteArray()); - try (var outputStream = new FileOutputStream(file)) { - input.transferTo(outputStream); - } - } catch (Exception e) { - logger.error("Error saving identity file: {}", e.getMessage()); + identityInfo.getDateAddedTimestamp()); + final var sql = ( + """ + INSERT OR REPLACE INTO %s (recipient_id, identity_key, added_timestamp, trust_level) + VALUES (?, ?, ?, ?) + """ + ).formatted(TABLE_IDENTITY); + try (final var statement = connection.prepareStatement(sql)) { + statement.setLong(1, identityInfo.getRecipientId().id()); + statement.setBytes(2, identityInfo.getIdentityKey().serialize()); + statement.setLong(3, identityInfo.getDateAddedTimestamp()); + statement.setInt(4, identityInfo.getTrustLevel().ordinal()); + statement.executeUpdate(); } } - private void deleteIdentityLocked(final RecipientId recipientId) { - cachedIdentities.remove(recipientId); - - final var file = getIdentityFile(recipientId); - if (!file.exists()) { - return; + private void deleteIdentity(final Connection connection, final RecipientId recipientId) throws SQLException { + final var sql = ( + """ + DELETE FROM %s AS i + WHERE i.recipient_id = ? + """ + ).formatted(TABLE_IDENTITY); + try (final var statement = connection.prepareStatement(sql)) { + statement.setLong(1, recipientId.id()); + statement.executeUpdate(); } + } + + private IdentityInfo getIdentityInfoFromResultSet(ResultSet resultSet) throws SQLException { try { - Files.delete(file.toPath()); - } catch (IOException e) { - logger.error("Failed to delete identity file {}: {}", file, e.getMessage()); + final var recipientId = recipientIdCreator.create(resultSet.getLong("recipient_id")); + final var id = new IdentityKey(resultSet.getBytes("identity_key")); + final var trustLevel = TrustLevel.fromInt(resultSet.getInt("trust_level")); + final var added = resultSet.getLong("added_timestamp"); + + return new IdentityInfo(recipientId, id, trustLevel, added); + } catch (InvalidKeyException e) { + logger.warn("Failed to load identity key, resetting: {}", e.getMessage()); + return null; } } - - private record IdentityStorage(String identityKey, int trustLevel, long addedTimestamp) {} } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/identities/LegacyIdentityKeyStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/identities/LegacyIdentityKeyStore.java new file mode 100644 index 00000000..669602e5 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/identities/LegacyIdentityKeyStore.java @@ -0,0 +1,102 @@ +package org.asamk.signal.manager.storage.identities; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.asamk.signal.manager.api.TrustLevel; +import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.storage.recipients.RecipientResolver; +import org.asamk.signal.manager.util.IOUtils; +import org.signal.libsignal.protocol.IdentityKey; +import org.signal.libsignal.protocol.InvalidKeyException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +public class LegacyIdentityKeyStore { + + private final static Logger logger = LoggerFactory.getLogger(LegacyIdentityKeyStore.class); + private static final ObjectMapper objectMapper = org.asamk.signal.manager.storage.Utils.createStorageObjectMapper(); + + public static void migrate( + final File identitiesPath, final RecipientResolver resolver, final IdentityKeyStore identityKeyStore + ) { + final var identities = getIdentities(identitiesPath, resolver); + identityKeyStore.addLegacyIdentities(identities); + removeIdentityFiles(identitiesPath); + } + + static final Pattern identityFileNamePattern = Pattern.compile("(\\d+)"); + + private static List getIdentities(final File identitiesPath, final RecipientResolver resolver) { + final var files = identitiesPath.listFiles(); + if (files == null) { + return List.of(); + } + return Arrays.stream(files) + .filter(f -> identityFileNamePattern.matcher(f.getName()).matches()) + .map(f -> resolver.resolveRecipient(Long.parseLong(f.getName()))) + .filter(Objects::nonNull) + .map(recipientId -> loadIdentityLocked(recipientId, identitiesPath)) + .filter(Objects::nonNull) + .toList(); + } + + private static File getIdentityFile(final RecipientId recipientId, final File identitiesPath) { + try { + IOUtils.createPrivateDirectories(identitiesPath); + } catch (IOException e) { + throw new AssertionError("Failed to create identities path", e); + } + return new File(identitiesPath, String.valueOf(recipientId.id())); + } + + private static IdentityInfo loadIdentityLocked(final RecipientId recipientId, final File identitiesPath) { + final var file = getIdentityFile(recipientId, identitiesPath); + if (!file.exists()) { + return null; + } + try (var inputStream = new FileInputStream(file)) { + var storage = objectMapper.readValue(inputStream, IdentityStorage.class); + + var id = new IdentityKey(Base64.getDecoder().decode(storage.identityKey())); + var trustLevel = TrustLevel.fromInt(storage.trustLevel()); + var added = storage.addedTimestamp(); + + return new IdentityInfo(recipientId, id, trustLevel, added); + } catch (IOException | InvalidKeyException e) { + logger.warn("Failed to load identity key: {}", e.getMessage()); + return null; + } + } + + private static void removeIdentityFiles(File identitiesPath) { + final var files = identitiesPath.listFiles(); + if (files == null) { + return; + } + + for (var file : files) { + try { + Files.delete(file.toPath()); + } catch (IOException e) { + logger.error("Failed to delete identity file {}: {}", file, e.getMessage()); + } + } + try { + Files.delete(identitiesPath.toPath()); + } catch (IOException e) { + logger.error("Failed to delete identity directory {}: {}", identitiesPath, e.getMessage()); + } + } + + private record IdentityStorage(String identityKey, int trustLevel, long addedTimestamp) {} +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/identities/SignalIdentityKeyStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/identities/SignalIdentityKeyStore.java index 06050875..c15b858e 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/identities/SignalIdentityKeyStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/identities/SignalIdentityKeyStore.java @@ -54,7 +54,8 @@ public class SignalIdentityKeyStore implements org.signal.libsignal.protocol.sta @Override public IdentityKey getIdentity(SignalProtocolAddress address) { var recipientId = resolveRecipient(address.getName()); - return identityKeyStore.getIdentity(recipientId); + final var identityInfo = identityKeyStore.getIdentityInfo(recipientId); + return identityInfo == null ? null : identityInfo.getIdentityKey(); } /** diff --git a/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java b/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java index e281ff88..45326fbc 100644 --- a/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java @@ -10,6 +10,7 @@ import org.asamk.signal.output.JsonWriter; import org.asamk.signal.output.OutputWriter; import org.asamk.signal.output.PlainTextWriter; import org.asamk.signal.util.CommandUtil; +import org.asamk.signal.util.DateUtils; import org.asamk.signal.util.Hex; import org.asamk.signal.util.Util; import org.slf4j.Logger; @@ -32,7 +33,7 @@ public class ListIdentitiesCommand implements JsonRpcLocalCommand { writer.println("{}: {} Added: {} Fingerprint: {} Safety Number: {}", theirId.recipient().getLegacyIdentifier(), theirId.trustLevel(), - theirId.dateAdded(), + DateUtils.formatTimestamp(theirId.dateAddedTimestamp()), Hex.toString(theirId.getFingerprint()), Util.formatSafetyNumber(theirId.safetyNumber())); } @@ -74,7 +75,7 @@ public class ListIdentitiesCommand implements JsonRpcLocalCommand { ? null : Base64.getEncoder().encodeToString(scannableSafetyNumber), id.trustLevel().name(), - id.dateAdded().getTime()); + id.dateAddedTimestamp()); }).toList(); writer.write(jsonIdentities); -- 2.50.1