diff --git a/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json b/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json index b298e7b0d..a53965c10 100644 --- a/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json +++ b/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/src/main/java/im/conversations/android/database/ConversationsDatabase.java b/app/src/main/java/im/conversations/android/database/ConversationsDatabase.java index ac734a35e..fab217091 100644 --- a/app/src/main/java/im/conversations/android/database/ConversationsDatabase.java +++ b/app/src/main/java/im/conversations/android/database/ConversationsDatabase.java @@ -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(); diff --git a/app/src/main/java/im/conversations/android/database/dao/CertificateTrustDao.java b/app/src/main/java/im/conversations/android/database/dao/CertificateTrustDao.java new file mode 100644 index 000000000..10e198c30 --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/dao/CertificateTrustDao.java @@ -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()); + } +} diff --git a/app/src/main/java/im/conversations/android/database/entity/CertificateTrustEntity.java b/app/src/main/java/im/conversations/android/database/entity/CertificateTrustEntity.java new file mode 100644 index 000000000..057c0cc95 --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/entity/CertificateTrustEntity.java @@ -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; + } +} diff --git a/app/src/main/java/im/conversations/android/repository/AccountRepository.java b/app/src/main/java/im/conversations/android/repository/AccountRepository.java index 70bddc1f2..4226d0007 100644 --- a/app/src/main/java/im/conversations/android/repository/AccountRepository.java +++ b/app/src/main/java/im/conversations/android/repository/AccountRepository.java @@ -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 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)); diff --git a/app/src/main/java/im/conversations/android/tls/ScopeFingerprint.java b/app/src/main/java/im/conversations/android/tls/ScopeFingerprint.java new file mode 100644 index 000000000..2e0f81282 --- /dev/null +++ b/app/src/main/java/im/conversations/android/tls/ScopeFingerprint.java @@ -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); + } +} diff --git a/app/src/main/java/im/conversations/android/ui/model/SetupViewModel.java b/app/src/main/java/im/conversations/android/ui/model/SetupViewModel.java index c43a5590b..c68c77bda 100644 --- a/app/src/main/java/im/conversations/android/ui/model/SetupViewModel.java +++ b/app/src/main/java/im/conversations/android/ui/model/SetupViewModel.java @@ -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> redirection = new MutableLiveData<>(); private final MutableLiveData trustDecision = new MutableLiveData<>(); - private final HashMap trustDecisions = new HashMap<>(); + private final HashMap trustDecisions = new HashMap<>(); - private final Function> - 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 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> 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 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 decision; - public TrustDecision( - TrustManager.ScopeFingerprint scopeFingerprint, SettableFuture decision) { + public TrustDecision(ScopeFingerprint scopeFingerprint, SettableFuture decision) { this.scopeFingerprint = scopeFingerprint; this.decision = decision; } diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/TrustManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/TrustManager.java index 1c5cdf31d..6ae817be3 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/TrustManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/TrustManager.java @@ -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> 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(