diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java index 0e6f9e4355..038bed8ef2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java @@ -2,8 +2,6 @@ package org.thoughtcrime.securesms.backup; import android.content.ActivityNotFoundException; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.net.Uri; @@ -39,6 +37,7 @@ public class BackupDialog { private static final String TAG = Log.tag(BackupDialog.class); + public static void showEnableBackupDialog(@NonNull Context context, @Nullable Intent backupDirectorySelectionIntent, @Nullable String backupDirectoryDisplayName, @@ -103,7 +102,7 @@ public class BackupDialog { textView.setOnClickListener(v -> checkBox.toggle()); dialog.findViewById(R.id.number_table).setOnClickListener(v -> { - ((ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", Util.join(password, " "))); + Util.copyToClipboardSensitive(context, Util.join(password, " ")); Toast.makeText(context, R.string.BackupDialog_copied_to_clipboard, Toast.LENGTH_LONG).show(); }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index baa97920ec..3aae064aa9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -65,7 +65,6 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega @VisibleForTesting const val TIER = "tier" - const val CLIPBOARD_TIMEOUT_SECONDS = 60 private const val PDF_MIME_TYPE = "application/pdf" @@ -192,7 +191,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega onSaveSuccessful = viewModel::onBackupKeySavedToPasswordManager ) }, - onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) }, + onCopyToClipboardClick = { Util.copyToClipboardSensitive(context, it) }, onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, onSaveStateCleared = viewModel::onBackupKeySaveStateCleared, @@ -212,7 +211,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null, onNavigationClick = viewModel::goToPreviousStage, mode = remember { MessageBackupsKeyRecordMode.Next(viewModel::goToNextStage) }, - onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) }, + onCopyToClipboardClick = { Util.copyToClipboardSensitive(context, it) }, onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested, onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed, onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted, @@ -337,7 +336,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega } } SignalLoginViewDetailsAction.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(requireContext())) - is SignalLoginViewDetailsAction.CopyTextToClipboard -> Util.copyToClipboard(requireContext(), action.text, CLIPBOARD_TIMEOUT_SECONDS) + is SignalLoginViewDetailsAction.CopyTextToClipboard -> Util.copyToClipboardSensitive(requireContext(), action.text) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt index 0a61262284..cf4b576e88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt @@ -91,8 +91,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.CommunicationActions import org.signal.core.ui.R as CoreUiR -private const val CLIPBOARD_TIMEOUT_SECONDS = 60 - @Stable sealed interface MessageBackupsKeyRecordMode { data class Next(val onNextClick: () -> Unit) : MessageBackupsKeyRecordMode @@ -133,7 +131,7 @@ fun MessageBackupsKeyRecordScreen( keySaveState = keySaveState, canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null, onNavigationClick = { onBackPressedDispatcher?.onBackPressed() }, - onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) }, + onCopyToClipboardClick = { Util.copyToClipboardSensitive(context, it) }, onRequestSaveToPasswordManager = backupKeyCredentialManagerHandler::onBackupKeySaveRequested, onConfirmSaveToPasswordManager = backupKeyCredentialManagerHandler::onBackupKeySaveConfirmed, onSaveToPasswordManagerComplete = backupKeyCredentialManagerHandler::onBackupKeySaveCompleted, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt index 106928a00b..be2dab05c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt @@ -16,10 +16,6 @@ import org.thoughtcrime.securesms.util.viewModel */ class ForgotBackupKeyFragment : ComposeFragment() { - companion object { - const val CLIPBOARD_TIMEOUT_SECONDS = 60 - } - private val viewModel: ForgotBackupKeyViewModel by viewModel { ForgotBackupKeyViewModel() } @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpSetupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpSetupFragment.kt index 47294a8905..b5f9c62800 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpSetupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpSetupFragment.kt @@ -54,7 +54,7 @@ class TotpSetupFragment : ComposeFragment() { when (action) { TotpSetupAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() is TotpSetupAction.LaunchTotpApp -> launchTotpApp(action.uri) - is TotpSetupAction.CopyKeyToClipboard -> Util.copyToClipboard(requireContext(), action.key) + is TotpSetupAction.CopyKeyToClipboard -> Util.copyToClipboardSensitive(requireContext(), action.key) TotpSetupAction.ShowKeyCopied -> toast(AppSettingsR.string.TotpSetupScreen__copied_to_clipboard) TotpSetupAction.ShowNoTotpAppFound -> toast(AppSettingsR.string.TotpSetupScreen__no_authenticator_app_found) TotpSetupAction.NavigateToCodeEntry -> findNavController().safeNavigate(R.id.action_authenticatorSetupFragment_to_authenticatorCodeEntryFragment) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt index 1dadc668b6..f2cb5c0a8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/signallogin/SignalLoginViewDetailsFragment.kt @@ -28,10 +28,6 @@ import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity */ class SignalLoginViewDetailsFragment : ComposeFragment() { - companion object { - private const val CLIPBOARD_TIMEOUT_SECONDS = 60 - } - private val viewModel: SignalLoginViewDetailsViewModel by viewModels() private val savePdfLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri: Uri? -> @@ -73,7 +69,7 @@ class SignalLoginViewDetailsFragment : ComposeFragment() { } } SignalLoginViewDetailsAction.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(requireContext())) - is SignalLoginViewDetailsAction.CopyTextToClipboard -> Util.copyToClipboard(requireContext(), action.text, CLIPBOARD_TIMEOUT_SECONDS) + is SignalLoginViewDetailsAction.CopyTextToClipboard -> Util.copyToClipboardSensitive(requireContext(), action.text) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseFragment.java index 33afa5bef9..4111221ecb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/backup/phrase/PaymentsRecoveryPhraseFragment.java @@ -1,10 +1,5 @@ package org.thoughtcrime.securesms.payments.backup.phrase; -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Intent; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; @@ -23,13 +18,10 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import org.signal.core.util.ClearClipboardAlarmReceiver; -import org.signal.core.util.PendingIntentFlags; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.payments.Mnemonic; -import org.signal.core.util.ServiceUtil; import org.signal.core.util.Util; import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; @@ -37,13 +29,13 @@ import org.thoughtcrime.securesms.util.navigation.SafeNavigation; import java.util.Arrays; import java.util.Comparator; import java.util.List; -import java.util.concurrent.TimeUnit; import java.util.stream.IntStream; import java.util.stream.Collectors; public class PaymentsRecoveryPhraseFragment extends Fragment { - private static final int SPAN_COUNT = 2; + private static final int SPAN_COUNT = 2; + private static final int CLIPBOARD_TIMEOUT_SECONDS = 30; public PaymentsRecoveryPhraseFragment() { super(R.layout.payments_recovery_phrase_fragment); @@ -102,14 +94,7 @@ public class PaymentsRecoveryPhraseFragment extends Fragment { } private void copyWordsToClipboard(List words) { - ClipboardManager clipboardManager = ServiceUtil.getClipboardManager(requireContext()); - clipboardManager.setPrimaryClip(ClipData.newPlainText(getString(R.string.app_name), Util.join(words, " "))); - - AlarmManager alarmManager = ServiceUtil.getAlarmManager(requireContext()); - Intent alarmIntent = new Intent(requireContext(), ClearClipboardAlarmReceiver.class); - PendingIntent pendingAlarmIntent = PendingIntent.getBroadcast(requireContext(), 0, alarmIntent, PendingIntentFlags.mutable()); - - alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30), pendingAlarmIntent); + Util.copyToClipboardSensitive(requireContext(), Util.join(words, " "), CLIPBOARD_TIMEOUT_SECONDS); } private void setUpForConfirmation(@NonNull TextView message, diff --git a/core/util/src/main/java/org/signal/core/util/Util.java b/core/util/src/main/java/org/signal/core/util/Util.java index 2a10381422..49fd67c1df 100644 --- a/core/util/src/main/java/org/signal/core/util/Util.java +++ b/core/util/src/main/java/org/signal/core/util/Util.java @@ -21,12 +21,15 @@ import android.app.ActivityManager; import android.app.AlarmManager; import android.app.PendingIntent; import android.content.ClipData; +import android.content.ClipDescription; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Typeface; import android.net.Uri; +import android.os.Build; +import android.os.PersistableBundle; import android.telephony.TelephonyManager; import android.text.Spannable; import android.text.SpannableString; @@ -64,6 +67,9 @@ public class Util { public static final String COPY_LABEL = "text\u00AD"; + /** How long a secret sits on the clipboard before it is cleared, when the caller doesn't specify. */ + public static final int SENSITIVE_CLIPBOARD_TIMEOUT_SECONDS = 60; + public static List asList(T... elements) { List result = new LinkedList<>(); Collections.addAll(result, elements); @@ -436,9 +442,26 @@ public class Util { ((ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText(COPY_LABEL, text)); } - public static void copyToClipboard(@NonNull Context context, @NonNull CharSequence text, int expiresInSeconds) { + /** + * Copies a secret to the clipboard, clearing it after {@link #SENSITIVE_CLIPBOARD_TIMEOUT_SECONDS}. See + * {@link #copyToClipboardSensitive(Context, CharSequence, int)}. + */ + public static void copyToClipboardSensitive(@NonNull Context context, @NonNull CharSequence text) { + copyToClipboardSensitive(context, text, SENSITIVE_CLIPBOARD_TIMEOUT_SECONDS); + } + + /** + * Copies a secret (backup passphrase, recovery phrase, key) to the clipboard. + * + * Flags the clip as sensitive, which stops the system clipboard UI on API 33+ from rendering a preview of the value and stops keyboards + * from offering it as a paste suggestion. It also schedules an alarm to clear the clipboard after {@code expiresInSeconds}. + */ + public static void copyToClipboardSensitive(@NonNull Context context, @NonNull CharSequence text, int expiresInSeconds) { ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); - clipboardManager.setPrimaryClip(ClipData.newPlainText(getPackageLabel(context), text)); + ClipData clip = ClipData.newPlainText(getPackageLabel(context), text); + + markSensitive(clip); + clipboardManager.setPrimaryClip(clip); AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); Intent alarmIntent = new Intent(context, ClearClipboardAlarmReceiver.class); @@ -447,6 +470,19 @@ public class Util { alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(expiresInSeconds), pendingAlarmIntent); } + /** + * Marks a clip as containing sensitive content. No-op below API 33. Does not prevent other apps from reading the clipboard on any API + * level, just affects how the system and keyboards display the clip. + */ + private static void markSensitive(@NonNull ClipData clip) { + if (Build.VERSION.SDK_INT >= 33) { + PersistableBundle extras = new PersistableBundle(); + extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true); + + clip.getDescription().setExtras(extras); + } + } + public static int parseInt(String integer, int defaultValue) { try { return Integer.parseInt(integer); diff --git a/core/util/src/test/java/org/signal/core/util/UtilTest_copyToClipboard.kt b/core/util/src/test/java/org/signal/core/util/UtilTest_copyToClipboard.kt new file mode 100644 index 0000000000..8fa3c6def0 --- /dev/null +++ b/core/util/src/test/java/org/signal/core/util/UtilTest_copyToClipboard.kt @@ -0,0 +1,123 @@ +package org.signal.core.util + +import android.app.AlarmManager +import android.app.Application +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isGreaterThanOrEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit + +/** + * Verifies that secrets copied to the clipboard are flagged sensitive and scheduled for clearing, and -- just as importantly -- that + * ordinary text is not. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class UtilTest_copyToClipboard { + + private val context: Context + get() = RuntimeEnvironment.getApplication() + + private val clipboardManager: ClipboardManager + get() = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + private val alarmManager: AlarmManager + get() = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + @Test + @Config(sdk = [33]) + fun `copyToClipboardSensitive - marks the clip as sensitive`() { + Util.copyToClipboardSensitive(context, SECRET, 60) + + val extras = clipboardManager.primaryClip!!.description.extras + assertThat(extras).isNotNull() + assertThat(extras!!.getBoolean(ClipDescription.EXTRA_IS_SENSITIVE)).isTrue() + } + + @Test + @Config(sdk = [33]) + fun `copyToClipboardSensitive - still copies the text`() { + Util.copyToClipboardSensitive(context, SECRET, 60) + + assertThat(clipboardManager.primaryClip!!.getItemAt(0).text.toString()).isEqualTo(SECRET) + } + + @Test + @Config(sdk = [33]) + fun `copyToClipboardSensitive - schedules a clear alarm`() { + val before = System.currentTimeMillis() + + Util.copyToClipboardSensitive(context, SECRET, 60) + + val alarms = shadowOf(alarmManager).scheduledAlarms + assertThat(alarms.size).isEqualTo(1) + assertThat(alarms[0].type).isEqualTo(AlarmManager.RTC_WAKEUP) + assertThat(alarms[0].triggerAtTime).isGreaterThanOrEqualTo(before + TimeUnit.SECONDS.toMillis(60)) + } + + @Test + @Config(sdk = [26]) + fun `copyToClipboardSensitive - below api 33, does not set extras`() { + Util.copyToClipboardSensitive(context, SECRET, 60) + + assertThat(clipboardManager.primaryClip!!.description.extras).isNull() + } + + @Test + @Config(sdk = [26]) + fun `copyToClipboardSensitive - below api 33, still schedules a clear alarm`() { + Util.copyToClipboardSensitive(context, SECRET, 60) + + assertThat(shadowOf(alarmManager).scheduledAlarms.size).isEqualTo(1) + } + + @Test + @Config(sdk = [33]) + fun `copyToClipboardSensitive - default overload marks the clip sensitive and uses the default timeout`() { + val before = System.currentTimeMillis() + + Util.copyToClipboardSensitive(context, SECRET) + + val extras = clipboardManager.primaryClip!!.description.extras + assertThat(extras).isNotNull() + assertThat(extras!!.getBoolean(ClipDescription.EXTRA_IS_SENSITIVE)).isTrue() + + val alarms = shadowOf(alarmManager).scheduledAlarms + assertThat(alarms.size).isEqualTo(1) + assertThat(alarms[0].triggerAtTime).isGreaterThanOrEqualTo(before + TimeUnit.SECONDS.toMillis(Util.SENSITIVE_CLIPBOARD_TIMEOUT_SECONDS.toLong())) + } + + @Test + @Config(sdk = [33]) + fun `copyToClipboard - does not mark the clip as sensitive`() { + Util.copyToClipboard(context, NOT_A_SECRET) + + assertThat(clipboardManager.primaryClip!!.description.extras).isNull() + } + + @Test + @Config(sdk = [33]) + fun `copyToClipboard - does not schedule a clear alarm`() { + Util.copyToClipboard(context, NOT_A_SECRET) + + assertThat(shadowOf(alarmManager).scheduledAlarms).isEmpty() + } + + companion object { + private const val SECRET = "12345 67890 12345 67890 12345 67890" + private const val NOT_A_SECRET = "https://signal.me/#p/+15551234567" + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index a4a5aaf1fa..11aa719df5 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -411,7 +411,6 @@ private const val AEP_FOR_LOCAL_BACKUP_RESULT = "aep_for_local_backup_result" private const val LOCAL_BACKUP_RESTORE_RESULT = "local_backup_restore_result" private const val PHONE_NUMBER_DISCOVERABILITY_RESULT = "phone_number_discoverability_result" private const val TWO_FACTOR_CODE_RESULT = "two_factor_code_result" -private const val CLIPBOARD_TIMEOUT_SECONDS = 60 /** Opens [url] in a browser, surfacing a toast if the device has none. */ private fun openUrl(context: Context, url: String) { @@ -852,7 +851,7 @@ private fun EntryProviderScope.navigationEntries( SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(context)) - is SignalLoginViewDetailsScreenActions.CopyTextToClipboard -> Util.copyToClipboard(context, action.text, CLIPBOARD_TIMEOUT_SECONDS) + is SignalLoginViewDetailsScreenActions.CopyTextToClipboard -> Util.copyToClipboardSensitive(context, action.text) } } @@ -889,7 +888,7 @@ private fun EntryProviderScope.navigationEntries( when (action) { SignalLoginViewDetailsForManualSaveScreenActions.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(context)) - is SignalLoginViewDetailsForManualSaveScreenActions.CopyTextToClipboard -> Util.copyToClipboard(context, action.text, CLIPBOARD_TIMEOUT_SECONDS) + is SignalLoginViewDetailsForManualSaveScreenActions.CopyTextToClipboard -> Util.copyToClipboardSensitive(context, action.text) } }