Move identity store to database

This commit is contained in:
AsamK 2022-06-10 14:34:24 +02:00
parent dc8b83a110
commit 0c4a037dde
11 changed files with 312 additions and 165 deletions

View file

@ -1067,10 +1067,11 @@
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","boolean","boolean"] }] "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, "allDeclaredFields":true,
"allDeclaredMethods":true, "queryAllDeclaredMethods":true,
"allDeclaredConstructors":true "queryAllDeclaredConstructors":true,
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","int","long"] }]
}, },
{ {
"name":"org.asamk.signal.manager.storage.profiles.LegacyProfileStore", "name":"org.asamk.signal.manager.storage.profiles.LegacyProfileStore",

View file

@ -1044,7 +1044,7 @@ class ManagerImpl implements Manager {
.computeSafetyNumber(identityInfo.getRecipientId(), identityInfo.getIdentityKey()), .computeSafetyNumber(identityInfo.getRecipientId(), identityInfo.getIdentityKey()),
scannableFingerprint == null ? null : scannableFingerprint.getSerialized(), scannableFingerprint == null ? null : scannableFingerprint.getSerialized(),
identityInfo.getTrustLevel(), identityInfo.getTrustLevel(),
identityInfo.getDateAdded()); identityInfo.getDateAddedTimestamp());
} }
@Override @Override

View file

@ -3,15 +3,13 @@ package org.asamk.signal.manager.api;
import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import java.util.Date;
public record Identity( public record Identity(
RecipientAddress recipient, RecipientAddress recipient,
IdentityKey identityKey, IdentityKey identityKey,
String safetyNumber, String safetyNumber,
byte[] scannableSafetyNumber, byte[] scannableSafetyNumber,
TrustLevel trustLevel, TrustLevel trustLevel,
Date dateAdded long dateAddedTimestamp
) { ) {
public byte[] getFingerprint() { public byte[] getFingerprint() {

View file

@ -138,7 +138,7 @@ public class SyncHelper {
verifiedMessage = new VerifiedMessage(address, verifiedMessage = new VerifiedMessage(address,
currentIdentity.getIdentityKey(), currentIdentity.getIdentityKey(),
currentIdentity.getTrustLevel().toVerifiedState(), currentIdentity.getTrustLevel().toVerifiedState(),
currentIdentity.getDateAdded().getTime()); currentIdentity.getDateAddedTimestamp());
} }
var profileKey = account.getProfileStore().getProfileKey(recipientId); var profileKey = account.getProfileStore().getProfileKey(recipientId);

View file

@ -3,6 +3,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.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.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;
@ -19,7 +20,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 = 6; private static final long DATABASE_VERSION = 7;
private AccountDatabase(final HikariDataSource dataSource) { private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, dataSource); super(logger, DATABASE_VERSION, dataSource);
@ -38,6 +39,7 @@ public class AccountDatabase extends Database {
SignedPreKeyStore.createSql(connection); SignedPreKeyStore.createSql(connection);
GroupStore.createSql(connection); GroupStore.createSql(connection);
SessionStore.createSql(connection); SessionStore.createSql(connection);
IdentityKeyStore.createSql(connection);
} }
@Override @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
);
""");
}
}
} }
} }

View 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.GroupStore;
import org.asamk.signal.manager.storage.groups.LegacyGroupStore; 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.LegacyIdentityKeyStore;
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;
import org.asamk.signal.manager.storage.messageCache.MessageCache; import org.asamk.signal.manager.storage.messageCache.MessageCache;
@ -646,6 +647,11 @@ public class SignalAccount implements Closeable {
LegacySessionStore.migrate(legacySessionsPath, getRecipientResolver(), getAciSessionStore()); LegacySessionStore.migrate(legacySessionsPath, getRecipientResolver(), getAciSessionStore());
migratedLegacyConfig = true; migratedLegacyConfig = true;
} }
final var legacyIdentitiesPath = getIdentitiesPath(dataPath, accountPath);
if (legacyIdentitiesPath.exists()) {
LegacyIdentityKeyStore.migrate(legacyIdentitiesPath, getRecipientResolver(), getIdentityKeyStore());
migratedLegacyConfig = true;
}
final var legacySignalProtocolStore = rootNode.hasNonNull("axolotlStore") final var legacySignalProtocolStore = rootNode.hasNonNull("axolotlStore")
? jsonProcessor.convertValue(Utils.getNotNullNode(rootNode, "axolotlStore"), ? jsonProcessor.convertValue(Utils.getNotNullNode(rootNode, "axolotlStore"),
LegacyJsonSignalProtocolStore.class) LegacyJsonSignalProtocolStore.class)
@ -753,7 +759,7 @@ public class SignalAccount implements Closeable {
logger.debug("Migrating legacy identity session store."); logger.debug("Migrating legacy identity session store.");
for (var identity : legacySignalProtocolStore.getLegacyIdentityKeyStore().getIdentities()) { for (var identity : legacySignalProtocolStore.getLegacyIdentityKeyStore().getIdentities()) {
RecipientId recipientId = getRecipientStore().resolveRecipientTrusted(identity.getAddress()); RecipientId recipientId = getRecipientStore().resolveRecipientTrusted(identity.getAddress());
getIdentityKeyStore().saveIdentity(recipientId, identity.getIdentityKey(), identity.getDateAdded()); getIdentityKeyStore().saveIdentity(recipientId, identity.getIdentityKey());
getIdentityKeyStore().setIdentityTrustLevel(recipientId, getIdentityKeyStore().setIdentityTrustLevel(recipientId,
identity.getIdentityKey(), identity.getIdentityKey(),
identity.getTrustLevel()); identity.getTrustLevel());
@ -1105,8 +1111,8 @@ public class SignalAccount implements Closeable {
public IdentityKeyStore getIdentityKeyStore() { public IdentityKeyStore getIdentityKeyStore() {
return getOrCreate(() -> identityKeyStore, return getOrCreate(() -> identityKeyStore,
() -> identityKeyStore = new IdentityKeyStore(getIdentitiesPath(dataPath, accountPath), () -> identityKeyStore = new IdentityKeyStore(getAccountDatabase(),
getRecipientResolver(), getRecipientIdCreator(),
trustNewIdentity)); trustNewIdentity));
} }

View file

@ -4,22 +4,20 @@ import org.asamk.signal.manager.api.TrustLevel;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import java.util.Date;
public class IdentityInfo { public class IdentityInfo {
private final RecipientId recipientId; private final RecipientId recipientId;
private final IdentityKey identityKey; private final IdentityKey identityKey;
private final TrustLevel trustLevel; private final TrustLevel trustLevel;
private final Date added; private final long addedTimestamp;
IdentityInfo( IdentityInfo(
final RecipientId recipientId, IdentityKey identityKey, TrustLevel trustLevel, Date added final RecipientId recipientId, IdentityKey identityKey, TrustLevel trustLevel, long addedTimestamp
) { ) {
this.recipientId = recipientId; this.recipientId = recipientId;
this.identityKey = identityKey; this.identityKey = identityKey;
this.trustLevel = trustLevel; this.trustLevel = trustLevel;
this.added = added; this.addedTimestamp = addedTimestamp;
} }
public RecipientId getRecipientId() { public RecipientId getRecipientId() {
@ -38,7 +36,7 @@ public class IdentityInfo {
return trustLevel == TrustLevel.TRUSTED_UNVERIFIED || trustLevel == TrustLevel.TRUSTED_VERIFIED; return trustLevel == TrustLevel.TRUSTED_UNVERIFIED || trustLevel == TrustLevel.TRUSTED_VERIFIED;
} }
public Date getDateAdded() { public long getDateAddedTimestamp() {
return this.added; return this.addedTimestamp;
} }
} }

View file

@ -1,90 +1,81 @@
package org.asamk.signal.manager.storage.identities; 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.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.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientResolver; import org.asamk.signal.manager.storage.recipients.RecipientIdCreator;
import org.asamk.signal.manager.util.IOUtils;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.state.IdentityKeyStore.Direction; import org.signal.libsignal.protocol.state.IdentityKeyStore.Direction;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream; import java.sql.Connection;
import java.io.ByteArrayOutputStream; import java.sql.ResultSet;
import java.io.File; import java.sql.SQLException;
import java.io.FileInputStream; import java.util.Collection;
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.util.List; 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.PublishSubject;
import io.reactivex.rxjava3.subjects.Subject;
public class IdentityKeyStore { public class IdentityKeyStore {
private final static Logger logger = LoggerFactory.getLogger(IdentityKeyStore.class); private final static Logger logger = LoggerFactory.getLogger(IdentityKeyStore.class);
private final ObjectMapper objectMapper = org.asamk.signal.manager.storage.Utils.createStorageObjectMapper(); private static final String TABLE_IDENTITY = "identity";
private final Database database;
private final Map<RecipientId, IdentityInfo> cachedIdentities = new HashMap<>(); private final RecipientIdCreator recipientIdCreator;
private final File identitiesPath;
private final RecipientResolver resolver;
private final TrustNewIdentity trustNewIdentity; private final TrustNewIdentity trustNewIdentity;
private final PublishSubject<RecipientId> identityChanges = PublishSubject.create(); private final PublishSubject<RecipientId> identityChanges = PublishSubject.create();
private boolean isRetryingDecryption = false; 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( public IdentityKeyStore(
final File identitiesPath, final RecipientResolver resolver, final TrustNewIdentity trustNewIdentity final Database database,
final RecipientIdCreator recipientIdCreator,
final TrustNewIdentity trustNewIdentity
) { ) {
this.identitiesPath = identitiesPath; this.database = database;
this.resolver = resolver; this.recipientIdCreator = recipientIdCreator;
this.trustNewIdentity = trustNewIdentity; this.trustNewIdentity = trustNewIdentity;
} }
public Subject<RecipientId> getIdentityChanges() { public Observable<RecipientId> getIdentityChanges() {
return identityChanges; return identityChanges;
} }
public boolean saveIdentity(final RecipientId recipientId, final IdentityKey identityKey) { 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) { if (isRetryingDecryption) {
return false; return false;
} }
synchronized (cachedIdentities) { try (final var connection = database.getConnection()) {
final var identityInfo = loadIdentityLocked(recipientId); final var identityInfo = loadIdentity(connection, recipientId);
if (identityInfo != null && identityInfo.getIdentityKey().equals(identityKey)) { if (identityInfo != null && identityInfo.getIdentityKey().equals(identityKey)) {
// Identity already exists, not updating the trust level // Identity already exists, not updating the trust level
logger.trace("Not storing new identity for recipient {}, identity already stored", recipientId); logger.trace("Not storing new identity for recipient {}, identity already stored", recipientId);
return false; return false;
} }
final var trustLevel = trustNewIdentity == TrustNewIdentity.ALWAYS || ( saveNewIdentity(connection, recipientId, identityKey, identityInfo == null);
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);
return true; 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) { public boolean setIdentityTrustLevel(RecipientId recipientId, IdentityKey identityKey, TrustLevel trustLevel) {
synchronized (cachedIdentities) { try (final var connection = database.getConnection()) {
final var identityInfo = loadIdentityLocked(recipientId); final var identityInfo = loadIdentity(connection, recipientId);
if (identityInfo == null) { if (identityInfo == null) {
logger.debug("Not updating trust level for recipient {}, identity not found", recipientId); logger.debug("Not updating trust level for recipient {}, identity not found", recipientId);
return false; return false;
@ -112,9 +103,11 @@ public class IdentityKeyStore {
final var newIdentityInfo = new IdentityInfo(recipientId, final var newIdentityInfo = new IdentityInfo(recipientId,
identityKey, identityKey,
trustLevel, trustLevel,
identityInfo.getDateAdded()); identityInfo.getDateAddedTimestamp());
storeIdentityLocked(recipientId, newIdentityInfo); storeIdentity(connection, newIdentityInfo);
return true; return true;
} catch (SQLException e) {
throw new RuntimeException("Failed update identity store", e);
} }
} }
@ -123,19 +116,19 @@ public class IdentityKeyStore {
return true; return true;
} }
synchronized (cachedIdentities) { try (final var connection = database.getConnection()) {
// TODO implement possibility for different handling of incoming/outgoing trust decisions // TODO implement possibility for different handling of incoming/outgoing trust decisions
var identityInfo = loadIdentityLocked(recipientId); var identityInfo = loadIdentity(connection, recipientId);
if (identityInfo == null) { if (identityInfo == null) {
logger.debug("Initial identity found for {}, saving.", recipientId); logger.debug("Initial identity found for {}, saving.", recipientId);
saveIdentity(recipientId, identityKey); saveNewIdentity(connection, recipientId, identityKey, true);
identityInfo = loadIdentityLocked(recipientId); identityInfo = loadIdentity(connection, recipientId);
} else if (!identityInfo.getIdentityKey().equals(identityKey)) { } else if (!identityInfo.getIdentityKey().equals(identityKey)) {
// Identity found, but different // Identity found, but different
if (direction == Direction.SENDING) { if (direction == Direction.SENDING) {
logger.debug("Changed identity found for {}, saving.", recipientId); logger.debug("Changed identity found for {}, saving.", recipientId);
saveIdentity(recipientId, identityKey); saveNewIdentity(connection, recipientId, identityKey, false);
identityInfo = loadIdentityLocked(recipientId); identityInfo = loadIdentity(connection, recipientId);
} else { } else {
logger.trace("Trusting identity for {} for {}: {}", recipientId, direction, false); logger.trace("Trusting identity for {} for {}: {}", recipientId, direction, false);
return false; return false;
@ -145,125 +138,156 @@ public class IdentityKeyStore {
final var isTrusted = identityInfo != null && identityInfo.isTrusted(); final var isTrusted = identityInfo != null && identityInfo.isTrusted();
logger.trace("Trusting identity for {} for {}: {}", recipientId, direction, isTrusted); logger.trace("Trusting identity for {} for {}: {}", recipientId, direction, isTrusted);
return isTrusted; return isTrusted;
} } catch (SQLException e) {
} throw new RuntimeException("Failed read from identity store", e);
public IdentityKey getIdentity(RecipientId recipientId) {
synchronized (cachedIdentities) {
var identity = loadIdentityLocked(recipientId);
return identity == null ? null : identity.getIdentityKey();
} }
} }
public IdentityInfo getIdentityInfo(RecipientId recipientId) { public IdentityInfo getIdentityInfo(RecipientId recipientId) {
synchronized (cachedIdentities) { try (final var connection = database.getConnection()) {
return loadIdentityLocked(recipientId); 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() { public List<IdentityInfo> getIdentities() {
final var files = identitiesPath.listFiles(); try (final var connection = database.getConnection()) {
if (files == null) { final var sql = (
return List.of(); """
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) { public void mergeRecipients(final RecipientId recipientId, final RecipientId toBeMergedRecipientId) {
synchronized (cachedIdentities) { try (final var connection = database.getConnection()) {
deleteIdentityLocked(toBeMergedRecipientId); 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();
}
deleteIdentity(connection, toBeMergedRecipientId);
connection.commit();
} catch (SQLException e) {
throw new RuntimeException("Failed update identity store", e);
} }
} }
public void deleteIdentity(final RecipientId recipientId) { public void deleteIdentity(final RecipientId recipientId) {
synchronized (cachedIdentities) { try (final var connection = database.getConnection()) {
deleteIdentityLocked(recipientId); deleteIdentity(connection, recipientId);
} catch (SQLException e) {
throw new RuntimeException("Failed update identity store", e);
} }
} }
private File getIdentityFile(final RecipientId recipientId) { void addLegacyIdentities(final Collection<IdentityInfo> identities) {
try { logger.debug("Migrating legacy identities to database");
IOUtils.createPrivateDirectories(identitiesPath); long start = System.nanoTime();
} catch (IOException e) { try (final var connection = database.getConnection()) {
throw new AssertionError("Failed to create identities path", e); connection.setAutoCommit(false);
} for (final var identityInfo : identities) {
return new File(identitiesPath, String.valueOf(recipientId.id())); storeIdentity(connection, identityInfo);
}
private IdentityInfo loadIdentityLocked(final RecipientId recipientId) {
{
final var session = cachedIdentities.get(recipientId);
if (session != null) {
return session;
} }
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); private IdentityInfo loadIdentity(
if (!file.exists()) { final Connection connection, final RecipientId recipientId
return null; ) throws SQLException {
} final var sql = (
try (var inputStream = new FileInputStream(file)) { """
var storage = objectMapper.readValue(inputStream, IdentityStorage.class); SELECT i.recipient_id, i.identity_key, i.added_timestamp, i.trust_level
FROM %s AS i
var id = new IdentityKey(Base64.getDecoder().decode(storage.identityKey())); WHERE i.recipient_id = ?
var trustLevel = TrustLevel.fromInt(storage.trustLevel()); """
var added = new Date(storage.addedTimestamp()); ).formatted(TABLE_IDENTITY);
try (final var statement = connection.prepareStatement(sql)) {
final var identityInfo = new IdentityInfo(recipientId, id, trustLevel, added); statement.setLong(1, recipientId.id());
cachedIdentities.put(recipientId, identityInfo); return Utils.executeQueryForOptional(statement, this::getIdentityInfoFromResultSet).orElse(null);
return identityInfo;
} catch (IOException | InvalidKeyException e) {
logger.warn("Failed to load identity key: {}", e.getMessage());
return null;
} }
} }
private void storeIdentityLocked(final RecipientId recipientId, final IdentityInfo identityInfo) { 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 storeIdentity(final Connection connection, final IdentityInfo identityInfo) throws SQLException {
logger.trace("Storing identity info for {}, trust: {}, added: {}", logger.trace("Storing identity info for {}, trust: {}, added: {}",
recipientId, identityInfo.getRecipientId(),
identityInfo.getTrustLevel(), identityInfo.getTrustLevel(),
identityInfo.getDateAdded()); identityInfo.getDateAddedTimestamp());
cachedIdentities.put(recipientId, identityInfo); final var sql = (
"""
var storage = new IdentityStorage(Base64.getEncoder().encodeToString(identityInfo.getIdentityKey().serialize()), INSERT OR REPLACE INTO %s (recipient_id, identity_key, added_timestamp, trust_level)
identityInfo.getTrustLevel().ordinal(), VALUES (?, ?, ?, ?)
identityInfo.getDateAdded().getTime()); """
).formatted(TABLE_IDENTITY);
final var file = getIdentityFile(recipientId); try (final var statement = connection.prepareStatement(sql)) {
// Write to memory first to prevent corrupting the file in case of serialization errors statement.setLong(1, identityInfo.getRecipientId().id());
try (var inMemoryOutput = new ByteArrayOutputStream()) { statement.setBytes(2, identityInfo.getIdentityKey().serialize());
objectMapper.writeValue(inMemoryOutput, storage); statement.setLong(3, identityInfo.getDateAddedTimestamp());
statement.setInt(4, identityInfo.getTrustLevel().ordinal());
var input = new ByteArrayInputStream(inMemoryOutput.toByteArray()); statement.executeUpdate();
try (var outputStream = new FileOutputStream(file)) {
input.transferTo(outputStream);
}
} catch (Exception e) {
logger.error("Error saving identity file: {}", e.getMessage());
} }
} }
private void deleteIdentityLocked(final RecipientId recipientId) { private void deleteIdentity(final Connection connection, final RecipientId recipientId) throws SQLException {
cachedIdentities.remove(recipientId); final var sql = (
"""
final var file = getIdentityFile(recipientId); DELETE FROM %s AS i
if (!file.exists()) { WHERE i.recipient_id = ?
return; """
).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 { try {
Files.delete(file.toPath()); final var recipientId = recipientIdCreator.create(resultSet.getLong("recipient_id"));
} catch (IOException e) { final var id = new IdentityKey(resultSet.getBytes("identity_key"));
logger.error("Failed to delete identity file {}: {}", file, e.getMessage()); 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) {}
} }

View file

@ -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) {}
}

View file

@ -54,7 +54,8 @@ public class SignalIdentityKeyStore implements org.signal.libsignal.protocol.sta
@Override @Override
public IdentityKey getIdentity(SignalProtocolAddress address) { public IdentityKey getIdentity(SignalProtocolAddress address) {
var recipientId = resolveRecipient(address.getName()); var recipientId = resolveRecipient(address.getName());
return identityKeyStore.getIdentity(recipientId); final var identityInfo = identityKeyStore.getIdentityInfo(recipientId);
return identityInfo == null ? null : identityInfo.getIdentityKey();
} }
/** /**

View file

@ -10,6 +10,7 @@ import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.output.OutputWriter; import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.output.PlainTextWriter; import org.asamk.signal.output.PlainTextWriter;
import org.asamk.signal.util.CommandUtil; import org.asamk.signal.util.CommandUtil;
import org.asamk.signal.util.DateUtils;
import org.asamk.signal.util.Hex; import org.asamk.signal.util.Hex;
import org.asamk.signal.util.Util; import org.asamk.signal.util.Util;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -32,7 +33,7 @@ public class ListIdentitiesCommand implements JsonRpcLocalCommand {
writer.println("{}: {} Added: {} Fingerprint: {} Safety Number: {}", writer.println("{}: {} Added: {} Fingerprint: {} Safety Number: {}",
theirId.recipient().getLegacyIdentifier(), theirId.recipient().getLegacyIdentifier(),
theirId.trustLevel(), theirId.trustLevel(),
theirId.dateAdded(), DateUtils.formatTimestamp(theirId.dateAddedTimestamp()),
Hex.toString(theirId.getFingerprint()), Hex.toString(theirId.getFingerprint()),
Util.formatSafetyNumber(theirId.safetyNumber())); Util.formatSafetyNumber(theirId.safetyNumber()));
} }
@ -74,7 +75,7 @@ public class ListIdentitiesCommand implements JsonRpcLocalCommand {
? null ? null
: Base64.getEncoder().encodeToString(scannableSafetyNumber), : Base64.getEncoder().encodeToString(scannableSafetyNumber),
id.trustLevel().name(), id.trustLevel().name(),
id.dateAdded().getTime()); id.dateAddedTimestamp());
}).toList(); }).toList();
writer.write(jsonIdentities); writer.write(jsonIdentities);