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,
"database": {
"version": 1,
"identityHash": "5152f8eab684376f6f4076cf392e22d7",
"identityHash": "bc04f3d0c58f7e50f5c7973a7a06c9eb",
"entities": [
{
"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",
"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": [],
"setupQueries": [
"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.BlockingDao;
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.DiscoDao;
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.BookmarkEntity;
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.DiscoEntity;
import im.conversations.android.database.entity.DiscoExtensionEntity;
@ -63,6 +65,7 @@ import im.conversations.android.database.entity.RosterItemGroupEntity;
BlockedItemEntity.class,
BookmarkEntity.class,
BookmarkGroupEntity.class,
CertificateTrustEntity.class,
ChatEntity.class,
DiscoEntity.class,
DiscoExtensionEntity.class,
@ -114,6 +117,8 @@ public abstract class ConversationsDatabase extends RoomDatabase {
public abstract BookmarkDao bookmarkDao();
public abstract CertificateTrustDao certificateTrustDao();
public abstract ChatDao chatDao();
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.database.CredentialStore;
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.AccountIdentifier;
import im.conversations.android.database.model.Connection;
import im.conversations.android.tls.ScopeFingerprint;
import im.conversations.android.xmpp.ConnectionPool;
import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.RegistrationManager;
@ -131,6 +133,20 @@ public class AccountRepository extends AbstractRepository {
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 AccountAlreadyExistsException(BareJid 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.Connection;
import im.conversations.android.repository.AccountRepository;
import im.conversations.android.tls.ScopeFingerprint;
import im.conversations.android.ui.Event;
import im.conversations.android.util.ConnectionStates;
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<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>>
trustDecisionCallback =
scopeFingerprint -> {
final var decision = this.trustDecisions.get(scopeFingerprint);
if (decision != null) {
LOGGER.info("Using previous trust decision ({})", decision);
return Futures.immediateFuture(decision);
}
LOGGER.info("Trust decision arrived in UI");
final SettableFuture<Boolean> settableFuture = SettableFuture.create();
final var trustDecision =
new TrustDecision(scopeFingerprint, settableFuture);
final var currentOperation = this.currentOperation;
if (currentOperation != null) {
currentOperation.cancel(false);
}
this.trustDecision.postValue(trustDecision);
this.redirection.postValue(new Event<>(Target.TRUST_CERTIFICATE));
return settableFuture;
};
private final Function<ScopeFingerprint, ListenableFuture<Boolean>> trustDecisionCallback =
scopeFingerprint -> {
final var decision = this.trustDecisions.get(scopeFingerprint);
if (decision != null) {
LOGGER.info("Using previous trust decision ({})", decision);
return Futures.immediateFuture(decision);
}
LOGGER.info("Trust decision arrived in UI");
final SettableFuture<Boolean> settableFuture = SettableFuture.create();
final var trustDecision = new TrustDecision(scopeFingerprint, settableFuture);
final var currentOperation = this.currentOperation;
if (currentOperation != null) {
currentOperation.cancel(false);
}
this.trustDecision.postValue(trustDecision);
this.redirection.postValue(new Event<>(Target.TRUST_CERTIFICATE));
return settableFuture;
};
private final AccountRepository accountRepository;
@ -189,13 +188,12 @@ public class SetupViewModel extends AndroidViewModel {
// TODO navigate back to sign in or show error?
return true;
}
LOGGER.info(
"trying to commit trust for fingerprint {}",
TrustManager.fingerprint(trustDecision.scopeFingerprint.fingerprint.array()));
LOGGER.info("committing trust for {}", trustDecision.scopeFingerprint);
this.accountRepository.setCertificateTrustedAsync(account, trustDecision.scopeFingerprint);
// in case the UI interface hook gets called again before this gets written to DB
this.trustDecisions.put(trustDecision.scopeFingerprint, true);
if (trustDecision.decision.isDone()) {
ConnectionPool.getInstance(getApplication()).reconnect(account);
this.accountRepository.reconnect(account);
LOGGER.info("it was already done. we should reconnect");
}
trustDecision.decision.set(true);
@ -265,6 +263,7 @@ public class SetupViewModel extends AndroidViewModel {
private void setAccount(@NonNull final Account account) {
this.account = account;
this.registerTrustDecisionCallback();
// TODO if the connection is already TLS_ERROR then do a quick reconnect
this.decideNextStep(Target.ENTER_ADDRESS, account);
}
@ -282,6 +281,7 @@ public class SetupViewModel extends AndroidViewModel {
final var optionalTrustManager = getTrustManager();
if (optionalTrustManager.isPresent()) {
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 final TrustManager.ScopeFingerprint scopeFingerprint;
public final ScopeFingerprint scopeFingerprint;
public final SettableFuture<Boolean> decision;
public TrustDecision(
TrustManager.ScopeFingerprint scopeFingerprint, SettableFuture<Boolean> decision) {
public TrustDecision(ScopeFingerprint scopeFingerprint, SettableFuture<Boolean> decision) {
this.scopeFingerprint = scopeFingerprint;
this.decision = decision;
}

View file

@ -3,16 +3,15 @@ package im.conversations.android.xmpp.manager;
import android.annotation.SuppressLint;
import android.content.Context;
import com.google.common.base.Joiner;
import com.google.common.base.Objects;
import com.google.common.base.Throwables;
import com.google.common.collect.Lists;
import com.google.common.hash.Hashing;
import com.google.common.primitives.Bytes;
import com.google.common.util.concurrent.ListenableFuture;
import im.conversations.android.AppSettings;
import im.conversations.android.tls.ScopeFingerprint;
import im.conversations.android.tls.TrustManagers;
import im.conversations.android.xmpp.XmppConnection;
import java.nio.ByteBuffer;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.concurrent.ExecutionException;
@ -73,7 +72,6 @@ public class TrustManager extends AbstractManager {
public void removeUserInterfaceCallback(
final Function<ScopeFingerprint, ListenableFuture<Boolean>> callback) {
if (this.userInterfaceCallback == callback) {
LOGGER.info("Remove user interface callback");
this.userInterfaceCallback = null;
}
}
@ -82,33 +80,6 @@ public class TrustManager extends AbstractManager {
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 final String scope;
@ -141,7 +112,10 @@ public class TrustManager extends AbstractManager {
final byte[] fingerprint =
Hashing.sha256().hashBytes(certificate.getEncoded()).asBytes();
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;
if (callback == null) {
throw new CertificateException(