From cd925d5f532aade7395d8e85e106a27f1eac3238 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Mon, 2 Feb 2026 14:12:16 -0500 Subject: [PATCH] Self-check key transparency. --- .../securesms/ApplicationContext.java | 2 + .../PhoneNumberPrivacySettingsViewModel.kt | 1 + .../ConversationListFragment.java | 8 +- .../securesms/database/RecipientTable.kt | 13 ++ .../dependencies/KeyTransparencyApi.kt | 8 +- .../securesms/jobs/CheckKeyTransparencyJob.kt | 208 ++++++++++++++++++ .../securesms/jobs/JobManagerFactories.java | 1 + .../securesms/keyvalue/MiscellaneousValues.kt | 18 ++ .../data/RegistrationRepository.kt | 4 + .../verify/SelfVerificationFailureSheet.kt | 194 ++++++++++++++++ .../SelfVerificationFailureViewModel.kt | 33 +++ .../verify/VerifySafetyNumberRepository.kt | 5 +- app/src/main/protowire/JobData.proto | 4 + app/src/main/res/values/strings.xml | 18 +- 14 files changed, 507 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/CheckKeyTransparencyJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/verify/SelfVerificationFailureSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/verify/SelfVerificationFailureViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index f03c0929eb..1f3ae1c77a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob; import org.thoughtcrime.securesms.jobs.BackupRefreshJob; import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob; import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob; +import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob; import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob; import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob; import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob; @@ -261,6 +262,7 @@ public class ApplicationContext extends Application implements AppForegroundObse checkFreeDiskSpace(); MemoryTracker.start(); BackupSubscriptionCheckJob.enqueueIfAble(); + CheckKeyTransparencyJob.enqueueIfNecessary(); AppDependencies.getAuthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE); AppDependencies.getUnauthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/pnp/PhoneNumberPrivacySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/pnp/PhoneNumberPrivacySettingsViewModel.kt index ddc62e478f..c9b909fca0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/pnp/PhoneNumberPrivacySettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/pnp/PhoneNumberPrivacySettingsViewModel.kt @@ -68,6 +68,7 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() { private fun setDiscoverableByPhoneNumber(discoverable: Boolean) { SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (discoverable) PhoneNumberDiscoverabilityMode.DISCOVERABLE else PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + SignalDatabase.recipients.clearSelfKeyTransparencyData() StorageSyncHelper.scheduleSyncForDataChange() AppDependencies.jobManager.startChain(RefreshAttributesJob()).then(RefreshOwnProfileJob()).enqueue() refresh() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index f99c0c3eaa..3732d5cab4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -161,8 +161,8 @@ import org.thoughtcrime.securesms.util.SignalProxyUtil; import org.thoughtcrime.securesms.util.SnapToTopDataObserver; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter; -import org.thoughtcrime.securesms.components.SignalProgressDialog; import org.thoughtcrime.securesms.util.views.Stub; +import org.thoughtcrime.securesms.verify.SelfVerificationFailureSheet; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.thoughtcrime.securesms.window.WindowSizeClassExtensionsKt; import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState; @@ -402,6 +402,12 @@ public class ConversationListFragment extends MainFragment implements Conversati onSearchQueryUpdated(query); } + if (SignalStore.settings().getAutomaticVerificationEnabled() && + SignalStore.misc().getHasKeyTransparencyFailure() && + !SignalStore.misc().getHasSeenKeyTransparencyFailure()) { + SelfVerificationFailureSheet.show(getParentFragmentManager()); + } + RatingManager.showRatingDialogIfNecessary(requireContext()); chatListBackHandler = new ChatListBackHandler(false); 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 edd588cc72..e200583b82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -2234,6 +2234,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da .values(NEEDS_PNI_SIGNATURE to 0) .run() + clearSelfKeyTransparencyData() SignalDatabase.pendingPniSignatureMessages.deleteAll() db.setTransactionSuccessful() @@ -2262,6 +2263,10 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } + if (id == Recipient.self().id) { + clearSelfKeyTransparencyData() + } + if (update(id, contentValuesOf(USERNAME to username))) { AppDependencies.databaseObserver.notifyRecipientChanged(id) rotateStorageId(id) @@ -4056,6 +4061,14 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da .run() } + fun clearSelfKeyTransparencyData() { + writableDatabase + .update(TABLE_NAME) + .values(KEY_TRANSPARENCY_DATA to null) + .where("$ACI_COLUMN = ?", Recipient.self().requireAci().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/dependencies/KeyTransparencyApi.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/KeyTransparencyApi.kt index 7f9a4b6c8d..db9c54b177 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/KeyTransparencyApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/KeyTransparencyApi.kt @@ -26,9 +26,9 @@ class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.Unauthenti /** * 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 { + suspend fun search(aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult { return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection -> - chatConnection.keyTransparencyClient().search(aci, aciIdentityKey, e164, unidentifiedAccessKey, null, keyTransparencyStore) + chatConnection.keyTransparencyClient().search(aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore) .mapWithCancellation( onSuccess = { RequestResult.Success(Unit) }, onError = { throwable -> @@ -60,9 +60,9 @@ class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.Unauthenti /** * 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 { + suspend fun monitor(monitorMode: KeyTransparency.MonitorMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult { return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection -> - chatConnection.keyTransparencyClient().monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, null, keyTransparencyStore) + chatConnection.keyTransparencyClient().monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore) .mapWithCancellation( onSuccess = { RequestResult.Success(Unit) }, onError = { throwable -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckKeyTransparencyJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckKeyTransparencyJob.kt new file mode 100644 index 0000000000..9d0f7b5f2b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CheckKeyTransparencyJob.kt @@ -0,0 +1,208 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.signal.libsignal.keytrans.KeyTransparencyException +import org.signal.libsignal.keytrans.VerificationFailedException +import org.signal.libsignal.net.AppExpiredException +import org.signal.libsignal.net.KeyTransparency +import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.usernames.Username +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.KeyTransparencyStore +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.CoroutineJob +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.protos.CheckKeyTransparencyJobData +import org.thoughtcrime.securesms.keyvalue.AccountValues +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.net.SignalNetwork +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.RemoteConfig +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +/** + * Checks verification of our own identifiers using key transparency. + */ +class CheckKeyTransparencyJob private constructor( + private val showFailure: Boolean, + parameters: Parameters +) : CoroutineJob(parameters) { + + companion object { + private val TAG = Log.tag(CheckKeyTransparencyJob::class) + const val KEY = "CheckKeyTransparencyJob" + + private val TIME_BETWEEN_CHECK = 7.days + + @JvmStatic + fun enqueueIfNecessary() { + if (!canRunJob()) { + return + } + + val nextCheckIn = SignalStore.misc.lastKeyTransparencyTime.milliseconds + TIME_BETWEEN_CHECK + + if (nextCheckIn.inWholeMilliseconds < System.currentTimeMillis()) { + AppDependencies.jobManager.add( + CheckKeyTransparencyJob( + showFailure = false, + parameters = Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setInitialDelay(5.minutes.inWholeMilliseconds) + .setGlobalPriority(Parameters.PRIORITY_LOWER) + .setMaxInstancesForFactory(2) + .build() + ) + ) + } + } + + /** + * Following a failure, runs another job that will now show an error if it fails again. + */ + fun enqueueFollowingFailure() { + if (!canRunJob()) { + return + } + + AppDependencies.jobManager.add( + CheckKeyTransparencyJob( + showFailure = true, + parameters = Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setInitialDelay(1.days.inWholeMilliseconds) + .setGlobalPriority(Parameters.PRIORITY_LOWER) + .build() + ) + ) + } + + private fun canRunJob(): Boolean { + return if (!RemoteConfig.keyTransparency) { + Log.i(TAG, "Remote config is not on. Exiting.") + false + } else if (!SignalStore.account.isRegistered) { + Log.i(TAG, "Account not registered. Exiting.") + false + } else if (!SignalStore.settings.automaticVerificationEnabled) { + Log.i(TAG, "Automatic verification disabled. Exiting.") + false + } else if (SignalStore.account.usernameSyncState != AccountValues.UsernameSyncState.IN_SYNC) { + Log.i(TAG, "Username is in a bad state. Exiting.") + false + } else if (!Recipient.self().hasAci || !Recipient.self().hasE164) { + Log.i(TAG, "Missing an ACI or E164. Exiting.") + false + } else { + true + } + } + } + + override suspend fun doRun(): Result { + if (!canRunJob()) { + return Result.failure() + } + + SignalStore.misc.lastKeyTransparencyTime = System.currentTimeMillis() + + val recipient = SignalDatabase.recipients.getRecord(Recipient.self().id) + val aciIdentityKey = SignalStore.account.aciIdentityKey.publicKey + val aci = recipient.aci!!.libSignalAci + + val (e164, unidentifiedAccessKey) = if (SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.DISCOVERABLE) { + Pair(recipient.e164!!, ProfileKeyUtil.profileKeyOrNull(recipient.profileKey).let { UnidentifiedAccess.deriveAccessKeyFrom(it) }) + } else { + Pair(null, null) + } + + val usernameHash = SignalStore.account.username?.let { Username(it).hash } + val firstSearch = recipient.keyTransparencyData == null + + val result = if (firstSearch) { + Log.i(TAG, "First search in key transparency") + SignalNetwork.keyTransparency.search(aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, KeyTransparencyStore) + } else { + Log.i(TAG, "Monitoring search in key transparency") + SignalNetwork.keyTransparency.monitor(KeyTransparency.MonitorMode.SELF, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, KeyTransparencyStore) + } + + Log.i(TAG, "Key transparency complete, result: $result") + return when (result) { + is RequestResult.Success -> { + SignalStore.misc.hasKeyTransparencyFailure = false + SignalStore.misc.hasSeenKeyTransparencyFailure = false + Result.success() + } + + is RequestResult.NonSuccess -> { + if (result.error.exception is IllegalArgumentException) { + Log.w(TAG, "KT store was corrupted. Restarting and then retrying.") + SignalStore.account.distinguishedHead = null + SignalDatabase.recipients.clearSelfKeyTransparencyData() + Result.retry(defaultBackoff()) + } else if (result.error.exception is VerificationFailedException || result.error.exception is KeyTransparencyException) { + if (!showFailure) { + Log.w(TAG, "Verification failure. Enqueuing this job again to run again a day.") + StorageSyncJob.forRemoteChange() + enqueueFollowingFailure() + } else { + Log.w(TAG, "Second verification failure. Showing failure sheet.") + markFailure() + } + Result.failure() + } else if (result.error.exception is AppExpiredException) { + Result.failure() + } else { + Log.w(TAG, "Unknown nonsuccess failure. Showing failure sheet.") + markFailure() + Result.failure() + } + } + is RequestResult.RetryableNetworkError -> { + if (result.retryAfter != null) { + Result.retry(result.retryAfter!!.toMillis()) + } else { + Result.retry(defaultBackoff()) + } + } + is RequestResult.ApplicationError -> { + Log.w(TAG, "Unknown application failure. Showing failure sheet.") + markFailure() + Result.failure() + } + } + } + + /** + * Flags a failure in key transparency. For internal users, always force it to be shown. + * For others, it will only show once and only be cleared on the next successful verification. + */ + private fun markFailure() { + SignalStore.misc.hasKeyTransparencyFailure = true + if (RemoteConfig.internalUser) { + SignalStore.misc.hasSeenKeyTransparencyFailure = false + } + } + + override fun serialize(): ByteArray { + return CheckKeyTransparencyJobData(showFailure).encode() + } + + override fun getFactoryKey(): String = KEY + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): CheckKeyTransparencyJob { + val jobData = CheckKeyTransparencyJobData.ADAPTER.decode(serializedData!!) + return CheckKeyTransparencyJob(jobData.showFailure, parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 9dae405514..786136d80a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -154,6 +154,7 @@ public final class JobManagerFactories { put(CallQualitySurveySubmissionJob.KEY, new CallQualitySurveySubmissionJob.Factory()); put(CallSyncEventJob.KEY, new CallSyncEventJob.Factory()); put(CancelRestoreMediaJob.KEY, new CancelRestoreMediaJob.Factory()); + put(CheckKeyTransparencyJob.KEY, new CheckKeyTransparencyJob.Factory()); put(CheckRestoreMediaLeftJob.KEY, new CheckRestoreMediaLeftJob.Factory()); put(CheckServiceReachabilityJob.KEY, new CheckServiceReachabilityJob.Factory()); put(CleanPreKeysJob.KEY, new CleanPreKeysJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt index 77d6fc24cc..c61b85662d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt @@ -43,6 +43,9 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto private const val NEW_LINKED_DEVICE_CREATED_TIME = "misc.new_linked_device_created_time" private const val STARTED_QUOTE_THUMBNAIL_MIGRATION = "misc.started_quote_thumbnail_migration" private const val PREFERRED_MAIN_ACTIVITY_ANCHOR_INDEX = "misc.preferred_main_activity_anchor_index" + private const val LAST_KEY_TRANSPARENCY_TIME = "misc.last_key_transparency_time" + private const val HAS_KEY_TRANSPARENCY_FAILURE = "misc.has_key_transparency_failure" + private const val HAS_SEEN_KEY_TRANSPARENCY_FAILURE = "misc.has_seen_key_transparency_failure" } public override fun onFirstEverAppLaunch() { @@ -291,4 +294,19 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto */ @get:JvmName("startedQuoteThumbnailMigration") var startedQuoteThumbnailMigration: Boolean by booleanValue(STARTED_QUOTE_THUMBNAIL_MIGRATION, false) + + /** + * The last time we ran key transparency against ourself + */ + var lastKeyTransparencyTime: Long by longValue(LAST_KEY_TRANSPARENCY_TIME, 0) + + /** + * Whether you are unable to run key transparency on yourself + */ + var hasKeyTransparencyFailure: Boolean by booleanValue(HAS_KEY_TRANSPARENCY_FAILURE, false) + + /** + * Whether you have seen the dialog on key transparency failure + */ + var hasSeenKeyTransparencyFailure: Boolean by booleanValue(HAS_SEEN_KEY_TRANSPARENCY_FAILURE, false) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt index 2d89e7e8ae..f365427a2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt @@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistratio import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.gcm.FcmUtil import org.thoughtcrime.securesms.jobmanager.runJobBlocking +import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob import org.thoughtcrime.securesms.jobs.PreKeysSyncJob import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob @@ -243,6 +244,9 @@ object RegistrationRepository { AppDependencies.startNetwork() PreKeysSyncJob.enqueue() + recipientTable.clearSelfKeyTransparencyData() + CheckKeyTransparencyJob.enqueueIfNecessary() + val jobManager = AppDependencies.jobManager if (data.linkedDeviceInfo == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/SelfVerificationFailureSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/SelfVerificationFailureSheet.kt new file mode 100644 index 0000000000..232a6759f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/SelfVerificationFailureSheet.kt @@ -0,0 +1,194 @@ +package org.thoughtcrime.securesms.verify + +import android.content.DialogInterface +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.horizontalGutters +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.SupportEmailUtil + +/** + * Sheet to prompt for debug logs when self key transparency fails + */ +class SelfVerificationFailureSheet : ComposeBottomSheetDialogFragment() { + + private val viewModel: SelfVerificationFailureViewModel by viewModels() + override val peekHeightPercentage: Float = 0.75f + + companion object { + + @JvmStatic + fun show(fragmentManager: FragmentManager) { + SignalStore.misc.hasSeenKeyTransparencyFailure = true + SelfVerificationFailureSheet().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + } + + @Composable + override fun SheetContent() { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(state.sendEmail) { + if (state.sendEmail && state.debugLogUrl != null) { + val subject = context.getString(R.string.SelfVerificationFailureSheet__email_subject) + val prefix = "\n${context.getString(R.string.HelpFragment__debug_log)} ${state.debugLogUrl}\n\n" + val body = SupportEmailUtil.generateSupportEmailBody(context, R.string.SelfVerificationFailureSheet__email_filter, prefix, null) + CommunicationActions.openEmail(context, SupportEmailUtil.getSupportEmailAddress(context), subject, body) + dismissAllowingStateLoss() + } else if (state.sendEmail) { + Toast.makeText(requireContext(), getString(R.string.HelpFragment__could_not_upload_logs), Toast.LENGTH_LONG).show() + dismissAllowingStateLoss() + } + } + + VerifyFailureSheet( + state, + onLearnMoreClicked = { + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.HelpFragment__link__debug_info)) + }, + onDismiss = { + dismissAllowingStateLoss() + }, + onSubmit = { + viewModel.submitLogs() + } + ) + } +} + +@Composable +fun VerifyFailureSheet( + state: VerificationUiState, + onLearnMoreClicked: () -> Unit = {}, + onDismiss: () -> Unit = {}, + onSubmit: () -> Unit = {} +) { + return Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .horizontalGutters() + ) { + BottomSheets.Handle() + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_error_circle_24), + contentDescription = null, + tint = Color(0xFFC88600), + modifier = Modifier + .padding(top = 24.dp, bottom = 8.dp) + .size(66.dp) + .background(color = Color(0xFFF9E4B6), shape = CircleShape) + .padding(12.dp) + ) + + Text( + text = stringResource(R.string.SelfVerificationFailureSheet__title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 12.dp), + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = buildAnnotatedString { + append(stringResource(id = R.string.SelfVerificationFailureSheet__body)) + append(" ") + + withLink( + LinkAnnotation.Clickable(tag = "learn-more") { onLearnMoreClicked() } + ) { + withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { + append(stringResource(id = R.string.SelfVerificationFailureSheet__learn_more)) + } + } + }, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 54.dp, bottom = 28.dp) + ) { + Buttons.LargeTonal( + onClick = onDismiss, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(id = R.string.SelfVerificationFailureSheet__no_thanks)) + } + Spacer(modifier = Modifier.size(12.dp)) + Buttons.LargeTonal( + onClick = if (state.showAsProgress) { + {} + } else { + onSubmit + }, + modifier = Modifier.weight(1f) + ) { + if (state.showAsProgress) { + CircularProgressIndicator( + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp) + ) + } else { + Text(stringResource(id = R.string.SelfVerificationFailureSheet__submit)) + } + } + } + } +} + +@DayNightPreviews +@Composable +fun VerifyFailureSheetPreview() { + Previews.BottomSheetContentPreview { + VerifyFailureSheet(state = VerificationUiState()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/SelfVerificationFailureViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/SelfVerificationFailureViewModel.kt new file mode 100644 index 0000000000..00664f947a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/SelfVerificationFailureViewModel.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.verify + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.util.orNull +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository + +class SelfVerificationFailureViewModel : ViewModel() { + + private val submitDebugLogRepository: SubmitDebugLogRepository = SubmitDebugLogRepository() + + private val internalUiState = MutableStateFlow(VerificationUiState()) + val uiState: StateFlow = internalUiState + + fun submitLogs() { + viewModelScope.launch { + internalUiState.update { it.copy(showAsProgress = true) } + submitDebugLogRepository.buildAndSubmitLog { result -> + internalUiState.update { it.copy(sendEmail = true, debugLogUrl = result.orNull()) } + } + } + } +} + +data class VerificationUiState( + val showAsProgress: Boolean = false, + val sendEmail: Boolean = false, + val debugLogUrl: String? = null +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberRepository.kt index d342af9299..e2a2b9ed8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberRepository.kt @@ -31,15 +31,14 @@ object VerifySafetyNumberRepository { 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) + SignalNetwork.keyTransparency.search(aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash = null, KeyTransparencyStore) } else { Log.i(TAG, "Monitoring search in key transparency") - SignalNetwork.keyTransparency.monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, KeyTransparencyStore) + SignalNetwork.keyTransparency.monitor(KeyTransparency.MonitorMode.OTHER, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash = null, KeyTransparencyStore) } Log.i(TAG, "Key transparency complete, result: $result") diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 82305dde57..40af718e57 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -263,4 +263,8 @@ message UnpinJobData { message CallQualitySurveySubmissionJobData { org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest request = 1; bool includeDebugLogs = 2; +} + +message CheckKeyTransparencyJobData{ + bool showFailure = 1; } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec055207e0..dd28f6f369 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3864,14 +3864,28 @@ Signal can only automatically verify the encryption in chats where you’re connected to someone via a phone number. If the chat was started with a username or a group in common, verify end-to-end encryption by comparing the numbers on the previous screen or scanning the code on their device. - Signal now auto-verifies end-to-end encryption + Signal can now auto-verify key encryption - When you verify a safety number, Signal will automatically confirm whether the connection is secure using a process called key transparency. You can still verify connections manually using a QR code or number. + For contacts you’re connected to by phone number, Signal can automatically confirm whether the connection is secure using a process called key transparency. For added security, you can still verify connections manually using a QR code or number. Learn more Verify + + Automatic Key Verification is currently unavailable for your device. Submit debug log? + + Debug logs helps us diagnose and fix the issue, and do not contain identifying information. + + No thanks + + Submit + + AutomaticKeyVerificationFailure + AutomaticKeyVerificationFailure + + Learn more + Scan the QR Code on your contact\'s device.