mirror of
https://github.com/AsamK/signal-cli
synced 2025-08-29 18:40:39 +00:00
Move identity store to database
This commit is contained in:
parent
dc8b83a110
commit
0c4a037dde
11 changed files with 312 additions and 165 deletions
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
storeIdentity(connection, identityInfo);
|
||||||
}
|
}
|
||||||
return new File(identitiesPath, String.valueOf(recipientId.id()));
|
connection.commit();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("Failed update identity store", e);
|
||||||
|
}
|
||||||
|
logger.debug("Complete identities migration took {}ms", (System.nanoTime() - start) / 1000000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IdentityInfo loadIdentityLocked(final RecipientId recipientId) {
|
private IdentityInfo loadIdentity(
|
||||||
{
|
final Connection connection, final RecipientId recipientId
|
||||||
final var session = cachedIdentities.get(recipientId);
|
) throws SQLException {
|
||||||
if (session != null) {
|
final var sql = (
|
||||||
return session;
|
"""
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final var file = getIdentityFile(recipientId);
|
private void saveNewIdentity(
|
||||||
if (!file.exists()) {
|
final Connection connection,
|
||||||
return null;
|
final RecipientId recipientId,
|
||||||
}
|
final IdentityKey identityKey,
|
||||||
try (var inputStream = new FileInputStream(file)) {
|
final boolean firstIdentity
|
||||||
var storage = objectMapper.readValue(inputStream, IdentityStorage.class);
|
) throws SQLException {
|
||||||
|
final var trustLevel = trustNewIdentity == TrustNewIdentity.ALWAYS || (
|
||||||
var id = new IdentityKey(Base64.getDecoder().decode(storage.identityKey()));
|
trustNewIdentity == TrustNewIdentity.ON_FIRST_USE && firstIdentity
|
||||||
var trustLevel = TrustLevel.fromInt(storage.trustLevel());
|
) ? TrustLevel.TRUSTED_UNVERIFIED : TrustLevel.UNTRUSTED;
|
||||||
var added = new Date(storage.addedTimestamp());
|
logger.debug("Storing new identity for recipient {} with trust {}", recipientId, trustLevel);
|
||||||
|
final var newIdentityInfo = new IdentityInfo(recipientId, identityKey, trustLevel, System.currentTimeMillis());
|
||||||
final var identityInfo = new IdentityInfo(recipientId, id, trustLevel, added);
|
storeIdentity(connection, newIdentityInfo);
|
||||||
cachedIdentities.put(recipientId, identityInfo);
|
identityChanges.onNext(recipientId);
|
||||||
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 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");
|
||||||
}
|
|
||||||
|
|
||||||
private record IdentityStorage(String identityKey, int trustLevel, long addedTimestamp) {}
|
return new IdentityInfo(recipientId, id, trustLevel, added);
|
||||||
|
} catch (InvalidKeyException e) {
|
||||||
|
logger.warn("Failed to load identity key, resetting: {}", e.getMessage());
|
||||||
|
return 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) {}
|
||||||
|
}
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue