]> nmode's Git Repositories - signal-cli/commitdiff
Move identity store to database
authorAsamK <asamk@gmx.de>
Fri, 10 Jun 2022 12:34:24 +0000 (14:34 +0200)
committerAsamK <asamk@gmx.de>
Sun, 28 Aug 2022 14:04:05 +0000 (16:04 +0200)
graalvm-config-dir/reflect-config.json
lib/src/main/java/org/asamk/signal/manager/ManagerImpl.java
lib/src/main/java/org/asamk/signal/manager/api/Identity.java
lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java
lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java
lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityInfo.java
lib/src/main/java/org/asamk/signal/manager/storage/identities/IdentityKeyStore.java
lib/src/main/java/org/asamk/signal/manager/storage/identities/LegacyIdentityKeyStore.java [new file with mode: 0644]
lib/src/main/java/org/asamk/signal/manager/storage/identities/SignalIdentityKeyStore.java
src/main/java/org/asamk/signal/commands/ListIdentitiesCommand.java

index 4942bda4b68fc0602d7d2bc259e08ca2a5843946..3bd9bf08b4deeccc447c67df88e52190ce8c3614 100644 (file)
   "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.LegacyIdentityKeyStore$IdentityStorage",
   "allDeclaredFields":true,
-  "allDeclaredMethods":true,
-  "allDeclaredConstructors":true
+  "queryAllDeclaredMethods":true,
+  "queryAllDeclaredConstructors":true,
+  "methods":[{"name":"<init>","parameterTypes":["java.lang.String","int","long"] }]
 },
 {
   "name":"org.asamk.signal.manager.storage.profiles.LegacyProfileStore",
index 54a6b9179deaa25b5c81b85625b7d627bf37cdf9..6c52d59e75860d2656f513f1c6c3aa0c88945338 100644 (file)
@@ -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
index 8785df7697e35864d506d11dd570d73e13b852f7..c4755576f6c48e902a855d10e5b40cc3bb8feb54 100644 (file)
@@ -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() {
index d192ae8f09cf0f75c69a7c2ec18210f1ba160a90..73662d22e6da6baa37c2e48f52013e750d039827 100644 (file)
@@ -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);
index ef88c227ca2c134f5fcc14e19d370466c08f34b8..48b82ea20b6598831ba7af0996b225cc8f1453c8 100644 (file)
@@ -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
+                                        );
+                                        """);
+            }
+        }
     }
 }
index cf98bffee9afdbf63f227541dfef54ddaadf23ad..e09cda3efd5bbd53c980af06dfe35a5c4b1282d1 100644 (file)
@@ -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));
     }
 
index 5a7324e5d14d991f5627f0f96cf3fa51c6cec177..571f564d86bc93b85beeecc01d0d8e47ada9a3be 100644 (file)
@@ -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;
     }
 }
index 4d08f89f388073ec9248acc89a1f39d0dd58d075..3971532f67b8df4243b962a7c30a0091d39b1e4f 100644 (file)
@@ -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<RecipientId, IdentityInfo> 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<RecipientId> 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<RecipientId> getIdentityChanges() {
+    public Observable<RecipientId> 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<IdentityInfo> 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<IdentityInfo> 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 (file)
index 0000000..669602e
--- /dev/null
@@ -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<IdentityInfo> 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) {}
+}
index 0605087571bc1efb78222b6495598992060acd95..c15b858e1e254ac454ef458a702b07f80a7e2139 100644 (file)
@@ -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();
     }
 
     /**
index e281ff88d74bef98418b564cd8db28ee9d5a5486..45326fbc5de0b16a314433f2d169e413e1116b89 100644 (file)
@@ -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);