Mark copied secrets as sensitive and auto-clear the backup passphrase.

Co-authored-by: Greyson Parrelil <greyson@signal.org>
This commit is contained in:
Ehren Kret
2026-09-10 17:10:15 -04:00
committed by Cody Henthorne
co-authored by Greyson Parrelil
parent c6ff93af98
commit e90e421fea
10 changed files with 174 additions and 43 deletions
@@ -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();
});
@@ -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)
}
}
@@ -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,
@@ -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
@@ -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)
@@ -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)
}
}
}
@@ -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<String> 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,
@@ -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 <T> List<T> asList(T... elements) {
List<T> 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);
@@ -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"
}
}
@@ -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<NavKey>.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<NavKey>.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)
}
}