diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 64571d4401..8905842e30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -199,6 +199,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da const val SORT_NAME = "sort_name" const val IDENTITY_STATUS = "identity_status" const val IDENTITY_KEY = "identity_key" + const val KEY_TRANSPARENCY_DATA = "key_transparency_data" @JvmField val CREATE_TABLE = @@ -267,7 +268,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da $NICKNAME_FAMILY_NAME TEXT DEFAULT NULL, $NICKNAME_JOINED_NAME TEXT DEFAULT NULL, $NOTE TEXT DEFAULT NULL, - $MESSAGE_EXPIRATION_TIME_VERSION INTEGER DEFAULT 1 NOT NULL + $MESSAGE_EXPIRATION_TIME_VERSION INTEGER DEFAULT 1 NOT NULL, + $KEY_TRANSPARENCY_DATA BLOB DEFAULT NULL ) """ @@ -331,7 +333,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da PHONE_NUMBER_SHARING, NICKNAME_GIVEN_NAME, NICKNAME_FAMILY_NAME, - NOTE + NOTE, + KEY_TRANSPARENCY_DATA ) private val ID_PROJECTION = arrayOf(ID) @@ -4026,6 +4029,25 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da .readToSingleLong(0L) } + fun getKeyTransparencyData(aci: ACI): ByteArray? { + return readableDatabase + .select(KEY_TRANSPARENCY_DATA) + .from(TABLE_NAME) + .where("$ACI_COLUMN = ?", aci.toString()) + .run() + .readToSingleObject { cursor -> + cursor.requireBlob(KEY_TRANSPARENCY_DATA) + } + } + + fun setKeyTransparencyData(aci: ACI, data: ByteArray?) { + writableDatabase + .update(TABLE_NAME) + .values(KEY_TRANSPARENCY_DATA to data) + .where("$ACI_COLUMN = ?", aci.toString()) + .run() + } + /** * Will update the database with the content values you specified. It will make an intelligent * query such that this will only return true if a row was *actually* updated. diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt index 21e432916f..3286497146 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt @@ -165,7 +165,8 @@ object RecipientTableCursorUtil { callLinkRoomId = cursor.requireString(RecipientTable.CALL_LINK_ROOM_ID)?.let { CallLinkRoomId.DatabaseSerializer.deserialize(it) }, phoneNumberSharing = cursor.requireInt(RecipientTable.PHONE_NUMBER_SHARING).let { RecipientTable.PhoneNumberSharingState.fromId(it) }, nickname = ProfileName.fromParts(cursor.requireString(RecipientTable.NICKNAME_GIVEN_NAME), cursor.requireString(RecipientTable.NICKNAME_FAMILY_NAME)), - note = cursor.requireString(RecipientTable.NOTE) + note = cursor.requireString(RecipientTable.NOTE), + keyTransparencyData = cursor.requireBlob(RecipientTable.KEY_TRANSPARENCY_DATA) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index b8c1a28827..584d1877b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -153,6 +153,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V296_RemovePollVote import org.thoughtcrime.securesms.database.helpers.migration.V297_AddPinnedMessageColumns import org.thoughtcrime.securesms.database.helpers.migration.V298_DoNotBackupReleaseNotes import org.thoughtcrime.securesms.database.helpers.migration.V299_AddAttachmentMetadataTable +import org.thoughtcrime.securesms.database.helpers.migration.V300_AddKeyTransparencyColumn import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -312,10 +313,11 @@ object SignalDatabaseMigrations { 296 to V296_RemovePollVoteConstraint, 297 to V297_AddPinnedMessageColumns, 298 to V298_DoNotBackupReleaseNotes, - 299 to V299_AddAttachmentMetadataTable + 299 to V299_AddAttachmentMetadataTable, + 300 to V300_AddKeyTransparencyColumn ) - const val DATABASE_VERSION = 299 + const val DATABASE_VERSION = 300 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V300_AddKeyTransparencyColumn.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V300_AddKeyTransparencyColumn.kt new file mode 100644 index 0000000000..fd985ecfa9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V300_AddKeyTransparencyColumn.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** + * Adds column to recipient to track key transparency data + */ +@Suppress("ClassName") +object V300_AddKeyTransparencyColumn : SignalDatabaseMigration { + + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE recipient ADD COLUMN key_transparency_data BLOB DEFAULT NULL") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/KeyTransparencyStore.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/KeyTransparencyStore.kt new file mode 100644 index 0000000000..10f69d67d0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/KeyTransparencyStore.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.database.model + +import org.signal.core.util.logging.Log.tag +import org.signal.libsignal.keytrans.Store +import org.signal.libsignal.protocol.ServiceId +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.keyvalue.SignalStore +import java.util.Optional + +/** + * Store used by [org.signal.libsignal.net.KeyTransparencyClient] during key transparency + */ +data object KeyTransparencyStore : Store { + + private val TAG: String = tag(KeyTransparencyStore::class.java) + + override fun getLastDistinguishedTreeHead(): Optional { + return Optional.ofNullable(SignalStore.account.distinguishedHead) + } + + override fun setLastDistinguishedTreeHead(lastDistinguishedTreeHead: ByteArray) { + SignalStore.account.distinguishedHead = lastDistinguishedTreeHead + } + + override fun getAccountData(libsignalAci: ServiceId.Aci): Optional { + val aci = org.signal.core.models.ServiceId.ACI.fromLibSignal(libsignalAci) + return Optional.ofNullable(SignalDatabase.recipients.getKeyTransparencyData(aci)) + } + + override fun setAccountData(libsignalAci: ServiceId.Aci, data: ByteArray) { + val aci = org.signal.core.models.ServiceId.ACI.fromLibSignal(libsignalAci) + SignalDatabase.recipients.setKeyTransparencyData(aci, data) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt index 58531326d3..375105268b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt @@ -81,7 +81,8 @@ data class RecipientRecord( val callLinkRoomId: CallLinkRoomId?, val phoneNumberSharing: PhoneNumberSharingState, val nickname: ProfileName, - val note: String? + val note: String?, + val keyTransparencyData: ByteArray? = null ) { fun e164Only(): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 5b0c2925eb..637cc45fd9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -369,6 +369,9 @@ object AppDependencies { val donationsApi: DonationsApi get() = networkModule.donationsApi + val keyTransparencyApi: KeyTransparencyApi + get() = networkModule.keyTransparencyApi + @JvmStatic val okHttpClient: OkHttpClient get() = networkModule.okHttpClient @@ -463,5 +466,6 @@ object AppDependencies { fun provideRemoteConfigApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): RemoteConfigApi fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi fun provideSvrBApi(libSignalNetwork: Network): SvrBApi + fun provideKeyTransparencyApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): KeyTransparencyApi } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index cbd13cde95..103bd0c6ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -579,6 +579,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { return new SvrBApi(libSignalNetwork); } + @Override + public @NonNull KeyTransparencyApi provideKeyTransparencyApi(@NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket) { + return new KeyTransparencyApi(unauthWebSocket); + } + @VisibleForTesting static class DynamicCredentialsProvider implements CredentialsProvider { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/KeyTransparencyApi.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/KeyTransparencyApi.kt new file mode 100644 index 0000000000..7f9a4b6c8d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/KeyTransparencyApi.kt @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.dependencies + +import org.signal.libsignal.internal.mapWithCancellation +import org.signal.libsignal.keytrans.KeyTransparencyException +import org.signal.libsignal.keytrans.VerificationFailedException +import org.signal.libsignal.net.AppExpiredException +import org.signal.libsignal.net.BadRequestError +import org.signal.libsignal.net.KeyTransparency +import org.signal.libsignal.net.NetworkException +import org.signal.libsignal.net.NetworkProtocolException +import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.net.RetryLaterException +import org.signal.libsignal.net.ServerSideErrorException +import org.signal.libsignal.net.TimeoutException +import org.signal.libsignal.net.getOrError +import org.signal.libsignal.protocol.IdentityKey +import org.signal.libsignal.protocol.ServiceId +import org.thoughtcrime.securesms.database.model.KeyTransparencyStore +import org.whispersystems.signalservice.api.websocket.SignalWebSocket + +/** + * Operations used when interacting with [org.signal.libsignal.net.KeyTransparencyClient] + */ +class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket) { + + /** + * Uses KT to verify recipient. This is an unauthenticated and should only be called the first time KT is being requested for this recipient. + */ + suspend fun search(aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String, unidentifiedAccessKey: ByteArray, keyTransparencyStore: KeyTransparencyStore): RequestResult { + return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection -> + chatConnection.keyTransparencyClient().search(aci, aciIdentityKey, e164, unidentifiedAccessKey, null, keyTransparencyStore) + .mapWithCancellation( + onSuccess = { RequestResult.Success(Unit) }, + onError = { throwable -> + when (throwable) { + is TimeoutException, + is ServerSideErrorException, + is NetworkException, + is NetworkProtocolException -> { + RequestResult.RetryableNetworkError(throwable, null) + } + is RetryLaterException -> { + RequestResult.RetryableNetworkError(throwable, throwable.duration) + } + is VerificationFailedException, + is KeyTransparencyException, + is AppExpiredException, + is IllegalArgumentException -> { + RequestResult.NonSuccess(KeyTransparencyError(throwable)) + } + else -> { + RequestResult.ApplicationError(throwable) + } + } + } + ) + }.getOrError() + } + + /** + * Monitors KT to verify recipient. This is an unauthenticated and should only be called following a successful [search]. + */ + suspend fun monitor(monitorMode: KeyTransparency.MonitorMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String, unidentifiedAccessKey: ByteArray, keyTransparencyStore: KeyTransparencyStore): RequestResult { + return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection -> + chatConnection.keyTransparencyClient().monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, null, keyTransparencyStore) + .mapWithCancellation( + onSuccess = { RequestResult.Success(Unit) }, + onError = { throwable -> + when (throwable) { + is TimeoutException, + is ServerSideErrorException, + is NetworkException, + is NetworkProtocolException -> { + RequestResult.RetryableNetworkError(throwable, null) + } + is RetryLaterException -> { + RequestResult.RetryableNetworkError(throwable, throwable.duration) + } + is VerificationFailedException, + is KeyTransparencyException, + is AppExpiredException, + is IllegalArgumentException -> { + RequestResult.NonSuccess(KeyTransparencyError(throwable)) + } + else -> { + RequestResult.ApplicationError(throwable) + } + } + } + ) + }.getOrError() + } +} + +data class KeyTransparencyError(val exception: Throwable) : BadRequestError diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index 12bde5077e..321ad36ada 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -225,6 +225,10 @@ class NetworkDependenciesModule( provider.provideSvrBApi(libsignalNetwork) } + val keyTransparencyApi: KeyTransparencyApi by lazy { + provider.provideKeyTransparencyApi(unauthWebSocket) + } + val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() .addInterceptor(StandardUserAgentInterceptor()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index 6bd72d2f27..0be7ff3f0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -88,6 +88,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) private const val KEY_ACCOUNT_ENTROPY_POOL = "account.account_entropy_pool" private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY = "account.restored_account_entropy_pool" private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY_FROM_PRIMARY = "account.restore_account_entropy_pool_primary" + private const val KEY_KT_DISTINGUISHED_HEAD = "account.key_transparency_distinguished_head" private val AEP_LOCK = ReentrantLock() } @@ -517,6 +518,17 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) .apply() } + var distinguishedHead: ByteArray? + get() { + return getBlob(KEY_KT_DISTINGUISHED_HEAD, null) + } + set(value) { + store + .beginWrite() + .putBlob(KEY_KT_DISTINGUISHED_HEAD, value) + .apply() + } + /** * There are some cases where our username may fall out of sync with the service. In particular, we may get a new value for our username from * storage service but then find that it doesn't match what's on the service. diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt index 1290f9aa69..49d7797b5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.net import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi import org.whispersystems.signalservice.api.account.AccountApi import org.whispersystems.signalservice.api.archive.ArchiveApi import org.whispersystems.signalservice.api.attachment.AttachmentApi @@ -98,4 +99,7 @@ object SignalNetwork { val svrB: SvrBApi get() = AppDependencies.svrBApi + + val keyTransparency: KeyTransparencyApi + get() = AppDependencies.keyTransparencyApi } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index 578a87a7f4..6fbd19db98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -120,7 +120,8 @@ class Recipient( private val groupRecord: Optional = Optional.empty(), val phoneNumberSharing: PhoneNumberSharingState = PhoneNumberSharingState.UNKNOWN, val nickname: ProfileName = ProfileName.EMPTY, - val note: String? = null + val note: String? = null, + val keyTransparencyData: ByteArray? = null ) { /** The recipient's [ServiceId], which could be either an [ACI] or [PNI]. */ @@ -822,7 +823,8 @@ class Recipient( callLinkRoomId == other.callLinkRoomId && phoneNumberSharing == other.phoneNumberSharing && nickname == other.nickname && - note == other.note + note == other.note && + keyTransparencyData.contentEquals(other.keyTransparencyData) } override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt index 3d14b8c8f3..fbf92f01c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt @@ -202,7 +202,8 @@ object RecipientCreator { groupRecord = groupRecord, phoneNumberSharing = record.phoneNumberSharing, nickname = record.nickname, - note = record.note + note = record.note, + keyTransparencyData = record.keyTransparencyData ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.kt index 8bdfa1cd39..380025eeaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.kt @@ -84,6 +84,13 @@ class VerifyDisplayFragment : Fragment() { binding.caption.setLinkColor(ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorPrimary)) viewModel.getAutomaticVerification().observe(viewLifecycleOwner) { status -> + if (status == AutomaticVerificationStatus.NONE) { + binding.autoVerifyContainer.setOnClickListener { + viewModel.verifyAutomatically() + } + } else { + binding.autoVerifyContainer.setOnClickListener(null) + } updateStatus(status) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberRepository.kt new file mode 100644 index 0000000000..d342af9299 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberRepository.kt @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.verify + +import org.signal.core.util.logging.Log +import org.signal.libsignal.net.KeyTransparency +import org.signal.libsignal.net.RequestResult +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil +import org.thoughtcrime.securesms.database.model.KeyTransparencyStore +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.net.SignalNetwork +import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess +import java.time.Duration + +/** + * Repository for safety numbers, namely to support key transparency / automatic verification + */ +object VerifySafetyNumberRepository { + + private val TAG = Log.tag(VerifySafetyNumberRepository::class.java) + + /** + * Given a recipient will try to verify via search (first time) or monitor (subsequent). + */ + suspend fun verifyAutomatically(recipient: Recipient): VerifyResult { + if (recipient.aci.isEmpty || recipient.e164.isEmpty) { + return VerifyResult.UnretryableFailure + } + + val identityRecord = AppDependencies.protocolStore.aci().identities().getIdentityRecord(recipient.id) + val aciIdentityKey = identityRecord.get().identityKey + val aci = recipient.requireAci().libSignalAci + val e164 = recipient.requireE164() + val unidentifiedAccessKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey).let { UnidentifiedAccess.deriveAccessKeyFrom(it) } + val monitorMode = if (recipient.isSelf) KeyTransparency.MonitorMode.SELF else KeyTransparency.MonitorMode.OTHER + val firstSearch = recipient.keyTransparencyData == null + + val result = if (firstSearch) { + Log.i(TAG, "First search in key transparency") + SignalNetwork.keyTransparency.search(aci, aciIdentityKey, e164, unidentifiedAccessKey, KeyTransparencyStore) + } else { + Log.i(TAG, "Monitoring search in key transparency") + SignalNetwork.keyTransparency.monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, KeyTransparencyStore) + } + + Log.i(TAG, "Key transparency complete, result: $result") + return when (result) { + is RequestResult.Success -> { + VerifyResult.Success + } + is RequestResult.NonSuccess -> { + if (result.error.exception is IllegalArgumentException) { + VerifyResult.CorruptedFailure + } else { + VerifyResult.UnretryableFailure + } + } + is RequestResult.RetryableNetworkError -> { + if (result.retryAfter != null) { + VerifyResult.RetryableFailure(result.retryAfter!!) + } else { + VerifyResult.UnretryableFailure + } + } + is RequestResult.ApplicationError -> VerifyResult.UnretryableFailure + } + } + + sealed interface VerifyResult { + /** Successful verification */ + data object Success : VerifyResult + + /** Retryable failure **/ + data class RetryableFailure(val duration: Duration) : VerifyResult + + /** Failure when either the head or the data is corrupted. Retryable if both are reset. */ + data object CorruptedFailure : VerifyResult + + /** Failures that should not be retried. */ + data object UnretryableFailure : VerifyResult + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberViewModel.kt index bfab7d7728..1ecf795d9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberViewModel.kt @@ -10,6 +10,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.IdentityKey @@ -17,6 +22,7 @@ import org.signal.libsignal.protocol.fingerprint.Fingerprint import org.signal.libsignal.protocol.fingerprint.NumericFingerprintGenerator import org.thoughtcrime.securesms.crypto.ReentrantSessionLock import org.thoughtcrime.securesms.database.IdentityTable +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -43,6 +49,52 @@ class VerifySafetyNumberViewModel( init { initializeFingerprints() + checkAutomaticVerificationEligibility() + } + + private fun checkAutomaticVerificationEligibility() { + if (recipient.get().e164.isEmpty || recipient.get().aci.isEmpty) { + automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_PERMANENT) + } + } + + fun verifyAutomatically(canRetry: Boolean = true) { + viewModelScope.launch(SignalDispatchers.IO) { + if (automaticVerificationLiveData.value == AutomaticVerificationStatus.UNAVAILABLE_PERMANENT || !isActive) { + return@launch + } + + automaticVerificationLiveData.postValue(AutomaticVerificationStatus.VERIFYING) + + when (val result = VerifySafetyNumberRepository.verifyAutomatically(recipient.get())) { + VerifySafetyNumberRepository.VerifyResult.Success -> { + automaticVerificationLiveData.postValue(AutomaticVerificationStatus.VERIFIED) + } + is VerifySafetyNumberRepository.VerifyResult.RetryableFailure -> { + if (canRetry) { + delay(result.duration.toMillis()) + verifyAutomatically(canRetry = false) + } else { + Log.i(TAG, "Got a retryable exception, but we already retried once. Ignoring.") + automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY) + } + } + VerifySafetyNumberRepository.VerifyResult.CorruptedFailure -> { + Log.w(TAG, "KT store was corrupted. Clearing everything and starting again.") + SignalStore.account.distinguishedHead = null + SignalDatabase.recipients.setKeyTransparencyData(recipient.get().requireAci(), null) + if (canRetry) { + verifyAutomatically(canRetry = false) + } else { + Log.i(TAG, "Store was corrupted and we can retry, but we already retried once. Ignoring.") + automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY) + } + } + VerifySafetyNumberRepository.VerifyResult.UnretryableFailure -> { + automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY) + } + } + } } private fun initializeFingerprints() { diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index ead7ad70de..c313f2468a 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -310,4 +310,8 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { override fun provideSvrBApi(libSignalNetwork: Network): SvrBApi { return mockk(relaxed = true) } + + override fun provideKeyTransparencyApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): KeyTransparencyApi { + return mockk(relaxed = true) + } }