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
@@ -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"
}
}