persist certificate trust to disk

This commit is contained in:
Daniel Gultsch 2023-03-02 13:44:29 +01:00
parent 177320d8fe
commit 0c4771e2a8
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
8 changed files with 226 additions and 61 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "5152f8eab684376f6f4076cf392e22d7", "identityHash": "bc04f3d0c58f7e50f5c7973a7a06c9eb",
"entities": [ "entities": [
{ {
"tableName": "account", "tableName": "account",
@ -946,6 +946,67 @@
} }
] ]
}, },
{
"tableName": "certificate_trust",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `scope` TEXT NOT NULL, `fingerprint` BLOB NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scope",
"columnName": "scope",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fingerprint",
"columnName": "fingerprint",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_certificate_trust_accountId_scope",
"unique": true,
"columnNames": [
"accountId",
"scope"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_certificate_trust_accountId_scope` ON `${TABLE_NAME}` (`accountId`, `scope`)"
}
],
"foreignKeys": [
{
"table": "account",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{ {
"tableName": "chat", "tableName": "chat",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `type` TEXT, `archived` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `accountId` INTEGER NOT NULL, `address` TEXT NOT NULL, `type` TEXT, `archived` INTEGER NOT NULL, FOREIGN KEY(`accountId`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@ -2376,7 +2437,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5152f8eab684376f6f4076cf392e22d7')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc04f3d0c58f7e50f5c7973a7a06c9eb')"
] ]
} }
} }

View file

@ -10,6 +10,7 @@ import im.conversations.android.database.dao.AvatarDao;
import im.conversations.android.database.dao.AxolotlDao; import im.conversations.android.database.dao.AxolotlDao;
import im.conversations.android.database.dao.BlockingDao; import im.conversations.android.database.dao.BlockingDao;
import im.conversations.android.database.dao.BookmarkDao; import im.conversations.android.database.dao.BookmarkDao;
import im.conversations.android.database.dao.CertificateTrustDao;
import im.conversations.android.database.dao.ChatDao; import im.conversations.android.database.dao.ChatDao;
import im.conversations.android.database.dao.DiscoDao; import im.conversations.android.database.dao.DiscoDao;
import im.conversations.android.database.dao.MessageDao; import im.conversations.android.database.dao.MessageDao;
@ -29,6 +30,7 @@ import im.conversations.android.database.entity.AxolotlSignedPreKeyEntity;
import im.conversations.android.database.entity.BlockedItemEntity; import im.conversations.android.database.entity.BlockedItemEntity;
import im.conversations.android.database.entity.BookmarkEntity; import im.conversations.android.database.entity.BookmarkEntity;
import im.conversations.android.database.entity.BookmarkGroupEntity; import im.conversations.android.database.entity.BookmarkGroupEntity;
import im.conversations.android.database.entity.CertificateTrustEntity;
import im.conversations.android.database.entity.ChatEntity; import im.conversations.android.database.entity.ChatEntity;
import im.conversations.android.database.entity.DiscoEntity; import im.conversations.android.database.entity.DiscoEntity;
import im.conversations.android.database.entity.DiscoExtensionEntity; import im.conversations.android.database.entity.DiscoExtensionEntity;
@ -63,6 +65,7 @@ import im.conversations.android.database.entity.RosterItemGroupEntity;
BlockedItemEntity.class, BlockedItemEntity.class,
BookmarkEntity.class, BookmarkEntity.class,
BookmarkGroupEntity.class, BookmarkGroupEntity.class,
CertificateTrustEntity.class,
ChatEntity.class, ChatEntity.class,
DiscoEntity.class, DiscoEntity.class,
DiscoExtensionEntity.class, DiscoExtensionEntity.class,
@ -114,6 +117,8 @@ public abstract class ConversationsDatabase extends RoomDatabase {
public abstract BookmarkDao bookmarkDao(); public abstract BookmarkDao bookmarkDao();
public abstract CertificateTrustDao certificateTrustDao();
public abstract ChatDao chatDao(); public abstract ChatDao chatDao();
public abstract DiscoDao discoDao(); public abstract DiscoDao discoDao();

View file

@ -0,0 +1,26 @@
package im.conversations.android.database.dao;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import im.conversations.android.database.entity.CertificateTrustEntity;
import im.conversations.android.database.model.Account;
import im.conversations.android.tls.ScopeFingerprint;
@Dao
public abstract class CertificateTrustDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract void insert(final CertificateTrustEntity certificateTrustEntity);
@Query(
"SELECT EXISTS (SELECT id FROM certificate_trust WHERE accountId=:account AND"
+ " scope=:scope AND fingerprint=:fingerprint)")
protected abstract boolean isTrusted(
final long account, final String scope, final byte[] fingerprint);
public boolean isTrusted(final Account account, final ScopeFingerprint scopeFingerprint) {
return isTrusted(account.id, scopeFingerprint.scope, scopeFingerprint.fingerprint.array());
}
}

View file

@ -0,0 +1,41 @@
package im.conversations.android.database.entity;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import im.conversations.android.tls.ScopeFingerprint;
@Entity(
tableName = "certificate_trust",
foreignKeys =
@ForeignKey(
entity = AccountEntity.class,
parentColumns = {"id"},
childColumns = {"accountId"},
onDelete = ForeignKey.CASCADE),
indices = {
@Index(
value = {"accountId", "scope"},
unique = true)
})
public class CertificateTrustEntity {
@PrimaryKey(autoGenerate = true)
public Long id;
@NonNull public Long accountId;
@NonNull public String scope;
@NonNull public byte[] fingerprint;
public static CertificateTrustEntity of(
final long accountId, final ScopeFingerprint scopeFingerprint) {
final var entity = new CertificateTrustEntity();
entity.accountId = accountId;
entity.scope = scopeFingerprint.scope;
entity.fingerprint = scopeFingerprint.fingerprint.array();
return entity;
}
}

View file

@ -9,9 +9,11 @@ import com.google.common.util.concurrent.MoreExecutors;
import im.conversations.android.IDs; import im.conversations.android.IDs;
import im.conversations.android.database.CredentialStore; import im.conversations.android.database.CredentialStore;
import im.conversations.android.database.entity.AccountEntity; import im.conversations.android.database.entity.AccountEntity;
import im.conversations.android.database.entity.CertificateTrustEntity;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.AccountIdentifier; import im.conversations.android.database.model.AccountIdentifier;
import im.conversations.android.database.model.Connection; import im.conversations.android.database.model.Connection;
import im.conversations.android.tls.ScopeFingerprint;
import im.conversations.android.xmpp.ConnectionPool; import im.conversations.android.xmpp.ConnectionPool;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.RegistrationManager; import im.conversations.android.xmpp.manager.RegistrationManager;
@ -131,6 +133,20 @@ public class AccountRepository extends AbstractRepository {
return database.accountDao().hasNoAccounts(); return database.accountDao().hasNoAccounts();
} }
public ListenableFuture<Void> setCertificateTrustedAsync(
final Account account, final ScopeFingerprint scopeFingerprint) {
return Futures.submit(
() -> setCertificateTrusted(account, scopeFingerprint),
database.getQueryExecutor());
}
private void setCertificateTrusted(
final Account account, final ScopeFingerprint scopeFingerprint) {
this.database
.certificateTrustDao()
.insert(CertificateTrustEntity.of(account.id, scopeFingerprint));
}
public static class AccountAlreadyExistsException extends IllegalStateException { public static class AccountAlreadyExistsException extends IllegalStateException {
public AccountAlreadyExistsException(BareJid address) { public AccountAlreadyExistsException(BareJid address) {
super(String.format("The account %s has already been setup", address)); super(String.format("The account %s has already been setup", address));

View file

@ -0,0 +1,43 @@
package im.conversations.android.tls;
import androidx.annotation.NonNull;
import com.google.common.base.MoreObjects;
import com.google.common.base.Objects;
import im.conversations.android.xmpp.manager.TrustManager;
import java.nio.ByteBuffer;
public class ScopeFingerprint {
public final String scope;
public final ByteBuffer fingerprint;
@NonNull
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("scope", scope)
.add("fingerprint", TrustManager.fingerprint(fingerprint.array()))
.toString();
}
public ScopeFingerprint(final String scope, final byte[] fingerprint) {
this(scope, ByteBuffer.wrap(fingerprint));
}
public ScopeFingerprint(final String scope, final ByteBuffer fingerprint) {
this.scope = scope;
this.fingerprint = fingerprint;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ScopeFingerprint that = (ScopeFingerprint) o;
return Objects.equal(scope, that.scope) && Objects.equal(fingerprint, that.fingerprint);
}
@Override
public int hashCode() {
return Objects.hashCode(scope, fingerprint);
}
}

View file

@ -19,6 +19,7 @@ import im.conversations.android.R;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.Connection; import im.conversations.android.database.model.Connection;
import im.conversations.android.repository.AccountRepository; import im.conversations.android.repository.AccountRepository;
import im.conversations.android.tls.ScopeFingerprint;
import im.conversations.android.ui.Event; import im.conversations.android.ui.Event;
import im.conversations.android.util.ConnectionStates; import im.conversations.android.util.ConnectionStates;
import im.conversations.android.xmpp.ConnectionException; import im.conversations.android.xmpp.ConnectionException;
@ -58,28 +59,26 @@ public class SetupViewModel extends AndroidViewModel {
private final MutableLiveData<Event<Target>> redirection = new MutableLiveData<>(); private final MutableLiveData<Event<Target>> redirection = new MutableLiveData<>();
private final MutableLiveData<TrustDecision> trustDecision = new MutableLiveData<>(); private final MutableLiveData<TrustDecision> trustDecision = new MutableLiveData<>();
private final HashMap<TrustManager.ScopeFingerprint, Boolean> trustDecisions = new HashMap<>(); private final HashMap<ScopeFingerprint, Boolean> trustDecisions = new HashMap<>();
private final Function<TrustManager.ScopeFingerprint, ListenableFuture<Boolean>> private final Function<ScopeFingerprint, ListenableFuture<Boolean>> trustDecisionCallback =
trustDecisionCallback = scopeFingerprint -> {
scopeFingerprint -> { final var decision = this.trustDecisions.get(scopeFingerprint);
final var decision = this.trustDecisions.get(scopeFingerprint); if (decision != null) {
if (decision != null) { LOGGER.info("Using previous trust decision ({})", decision);
LOGGER.info("Using previous trust decision ({})", decision); return Futures.immediateFuture(decision);
return Futures.immediateFuture(decision); }
} LOGGER.info("Trust decision arrived in UI");
LOGGER.info("Trust decision arrived in UI"); final SettableFuture<Boolean> settableFuture = SettableFuture.create();
final SettableFuture<Boolean> settableFuture = SettableFuture.create(); final var trustDecision = new TrustDecision(scopeFingerprint, settableFuture);
final var trustDecision = final var currentOperation = this.currentOperation;
new TrustDecision(scopeFingerprint, settableFuture); if (currentOperation != null) {
final var currentOperation = this.currentOperation; currentOperation.cancel(false);
if (currentOperation != null) { }
currentOperation.cancel(false); this.trustDecision.postValue(trustDecision);
} this.redirection.postValue(new Event<>(Target.TRUST_CERTIFICATE));
this.trustDecision.postValue(trustDecision); return settableFuture;
this.redirection.postValue(new Event<>(Target.TRUST_CERTIFICATE)); };
return settableFuture;
};
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
@ -189,13 +188,12 @@ public class SetupViewModel extends AndroidViewModel {
// TODO navigate back to sign in or show error? // TODO navigate back to sign in or show error?
return true; return true;
} }
LOGGER.info( LOGGER.info("committing trust for {}", trustDecision.scopeFingerprint);
"trying to commit trust for fingerprint {}", this.accountRepository.setCertificateTrustedAsync(account, trustDecision.scopeFingerprint);
TrustManager.fingerprint(trustDecision.scopeFingerprint.fingerprint.array()));
// in case the UI interface hook gets called again before this gets written to DB // in case the UI interface hook gets called again before this gets written to DB
this.trustDecisions.put(trustDecision.scopeFingerprint, true); this.trustDecisions.put(trustDecision.scopeFingerprint, true);
if (trustDecision.decision.isDone()) { if (trustDecision.decision.isDone()) {
ConnectionPool.getInstance(getApplication()).reconnect(account); this.accountRepository.reconnect(account);
LOGGER.info("it was already done. we should reconnect"); LOGGER.info("it was already done. we should reconnect");
} }
trustDecision.decision.set(true); trustDecision.decision.set(true);
@ -265,6 +263,7 @@ public class SetupViewModel extends AndroidViewModel {
private void setAccount(@NonNull final Account account) { private void setAccount(@NonNull final Account account) {
this.account = account; this.account = account;
this.registerTrustDecisionCallback(); this.registerTrustDecisionCallback();
// TODO if the connection is already TLS_ERROR then do a quick reconnect
this.decideNextStep(Target.ENTER_ADDRESS, account); this.decideNextStep(Target.ENTER_ADDRESS, account);
} }
@ -282,6 +281,7 @@ public class SetupViewModel extends AndroidViewModel {
final var optionalTrustManager = getTrustManager(); final var optionalTrustManager = getTrustManager();
if (optionalTrustManager.isPresent()) { if (optionalTrustManager.isPresent()) {
optionalTrustManager.get().setUserInterfaceCallback(this.trustDecisionCallback); optionalTrustManager.get().setUserInterfaceCallback(this.trustDecisionCallback);
LOGGER.info("Registered user interface callback");
} }
} }
@ -473,11 +473,10 @@ public class SetupViewModel extends AndroidViewModel {
} }
public static class TrustDecision { public static class TrustDecision {
public final TrustManager.ScopeFingerprint scopeFingerprint; public final ScopeFingerprint scopeFingerprint;
public final SettableFuture<Boolean> decision; public final SettableFuture<Boolean> decision;
public TrustDecision( public TrustDecision(ScopeFingerprint scopeFingerprint, SettableFuture<Boolean> decision) {
TrustManager.ScopeFingerprint scopeFingerprint, SettableFuture<Boolean> decision) {
this.scopeFingerprint = scopeFingerprint; this.scopeFingerprint = scopeFingerprint;
this.decision = decision; this.decision = decision;
} }

View file

@ -3,16 +3,15 @@ package im.conversations.android.xmpp.manager;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import com.google.common.base.Joiner; import com.google.common.base.Joiner;
import com.google.common.base.Objects;
import com.google.common.base.Throwables; import com.google.common.base.Throwables;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import com.google.common.hash.Hashing; import com.google.common.hash.Hashing;
import com.google.common.primitives.Bytes; import com.google.common.primitives.Bytes;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import im.conversations.android.AppSettings; import im.conversations.android.AppSettings;
import im.conversations.android.tls.ScopeFingerprint;
import im.conversations.android.tls.TrustManagers; import im.conversations.android.tls.TrustManagers;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import java.nio.ByteBuffer;
import java.security.cert.CertificateException; import java.security.cert.CertificateException;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
@ -73,7 +72,6 @@ public class TrustManager extends AbstractManager {
public void removeUserInterfaceCallback( public void removeUserInterfaceCallback(
final Function<ScopeFingerprint, ListenableFuture<Boolean>> callback) { final Function<ScopeFingerprint, ListenableFuture<Boolean>> callback) {
if (this.userInterfaceCallback == callback) { if (this.userInterfaceCallback == callback) {
LOGGER.info("Remove user interface callback");
this.userInterfaceCallback = null; this.userInterfaceCallback = null;
} }
} }
@ -82,33 +80,6 @@ public class TrustManager extends AbstractManager {
return new ScopedTrustManager(scope); return new ScopedTrustManager(scope);
} }
public static class ScopeFingerprint {
public final String scope;
public final ByteBuffer fingerprint;
public ScopeFingerprint(final String scope, final byte[] fingerprint) {
this(scope, ByteBuffer.wrap(fingerprint));
}
public ScopeFingerprint(final String scope, final ByteBuffer fingerprint) {
this.scope = scope;
this.fingerprint = fingerprint;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ScopeFingerprint that = (ScopeFingerprint) o;
return Objects.equal(scope, that.scope) && Objects.equal(fingerprint, that.fingerprint);
}
@Override
public int hashCode() {
return Objects.hashCode(scope, fingerprint);
}
}
private class ScopedTrustManager implements X509TrustManager { private class ScopedTrustManager implements X509TrustManager {
private final String scope; private final String scope;
@ -141,7 +112,10 @@ public class TrustManager extends AbstractManager {
final byte[] fingerprint = final byte[] fingerprint =
Hashing.sha256().hashBytes(certificate.getEncoded()).asBytes(); Hashing.sha256().hashBytes(certificate.getEncoded()).asBytes();
final var scopeFingerprint = new ScopeFingerprint(scope, fingerprint); final var scopeFingerprint = new ScopeFingerprint(scope, fingerprint);
LOGGER.info("Looking up {} in database", fingerprint(fingerprint)); if (getDatabase().certificateTrustDao().isTrusted(getAccount(), scopeFingerprint)) {
LOGGER.info("Found {} in database", scopeFingerprint);
return;
}
final var callback = TrustManager.this.userInterfaceCallback; final var callback = TrustManager.this.userInterfaceCallback;
if (callback == null) { if (callback == null) {
throw new CertificateException( throw new CertificateException(