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 18be4ac78f..4b17253ef1 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 @@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +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.Dialogs @@ -59,11 +60,15 @@ import org.signal.core.ui.compose.horizontalGutters import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.Util import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.ui.warning.ClipStage +import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetContent +import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetEvent import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState import org.thoughtcrime.securesms.fonts.MonoTypeface import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository import org.thoughtcrime.securesms.util.storage.CredentialManagerError import org.thoughtcrime.securesms.util.storage.CredentialManagerResult @@ -120,6 +125,7 @@ fun MessageBackupsKeyRecordScreen( * Screen displaying the backup key allowing the user to write it down * or copy it. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun MessageBackupsKeyRecordScreen( backupKey: String, @@ -145,6 +151,39 @@ fun MessageBackupsKeyRecordScreen( RecordScreenBackHandler() } + var displayRecoveryKeyCopyWarning by remember { mutableStateOf(false) } + if (displayRecoveryKeyCopyWarning) { + val context = LocalContext.current + val url = stringResource(R.string.recovery_key_phishing_support_url) + val events: (RecoveryKeyWarningSheetEvent) -> Unit = { + when (it) { + RecoveryKeyWarningSheetEvent.DoNotShareClick -> error("Not supported") + RecoveryKeyWarningSheetEvent.GotItClick -> { + onCopyToClipboardClick(backupKeyString) + displayRecoveryKeyCopyWarning = false + } + RecoveryKeyWarningSheetEvent.LearnMoreClick -> { + CommunicationActions.openBrowserLink(context, url) + displayRecoveryKeyCopyWarning = false + } + + RecoveryKeyWarningSheetEvent.PasteKeyClick -> error("Not supported") + RecoveryKeyWarningSheetEvent.ShareKeyClick -> error("Not supported") + } + } + + ModalBottomSheet( + onDismissRequest = { displayRecoveryKeyCopyWarning = false }, + dragHandle = { BottomSheets.Handle() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + RecoveryKeyWarningSheetContent( + clipStage = ClipStage.COPY, + events = events + ) + } + } + Scaffolds.Settings( title = "", navigationIcon = SignalIcons.ArrowStart.imageVector, @@ -227,7 +266,13 @@ fun MessageBackupsKeyRecordScreen( item { Buttons.Small( - onClick = { onCopyToClipboardClick(backupKeyString) } + onClick = { + if (mode is MessageBackupsKeyRecordMode.CreateNewKey) { + displayRecoveryKeyCopyWarning = true + } else { + onCopyToClipboardClick(backupKeyString) + } + } ) { Text( text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetector.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetector.kt new file mode 100644 index 0000000000..c112f85440 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetector.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.warning + +import org.signal.core.models.AccountEntropyPool + +/** + * Detects whether a block of text contains the user's own [AccountEntropyPool] (recovery key). + * + * We scan anywhere within the text and try to match the key in as many forms as possible: + * upper/lowercase, with or without grouping spaces, and with or without the display characters + * (e.g. '#'/'=') used to disambiguate 'O'/'0'. Matching against the user's actual key (rather than + * just the AEP shape) avoids false positives on any 64-character in-alphabet string. + */ +object RecoveryKeyDetector { + + /** + * @param text the text to scan + * @param recoveryKey the user's own recovery key, or null if they don't have one yet + * @return true if [text] contains [recoveryKey] in any of its accepted forms. Always false when + * [recoveryKey] is null, so callers can bypass the check entirely for users without a key. + */ + fun containsRecoveryKey(text: String?, recoveryKey: AccountEntropyPool?): Boolean { + if (recoveryKey == null || text.isNullOrBlank() || text.length < AccountEntropyPool.LENGTH) { + return false + } + + val normalized = AccountEntropyPool.removeIllegalCharacters(AccountEntropyPool.formatForStorage(text)).lowercase() + + return normalized.contains(recoveryKey.value) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteGuard.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteGuard.kt new file mode 100644 index 0000000000..f39bcf619f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteGuard.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.warning + +import androidx.fragment.app.Fragment +import org.thoughtcrime.securesms.components.ComposeText +import org.thoughtcrime.securesms.keyvalue.SignalStore + +/** + * Wires this [ComposeText] so that pasting the user's own recovery key first shows + * [RecoveryKeyPasteWarningFragment], warning against sharing it. The paste only completes if the + * user explicitly confirms via that warning. + * + * Must be called once the [host]'s view has been created, as it registers a fragment result + * listener scoped to the host's view lifecycle. + * + * @param onWarningShown invoked just before the warning is shown. Hosts that auto-dismiss when the + * keyboard hides (e.g. [org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment]) can use + * this to suppress that behavior while the warning is up. + * @param onWarningDismissed invoked when the warning is dismissed by any path, after the paste (if + * any) has been applied. Hosts can use this to restore the suppressed state and re-focus the input. + */ +fun ComposeText.guardAgainstRecoveryKeyPaste( + host: Fragment, + onWarningShown: () -> Unit = {}, + onWarningDismissed: () -> Unit = {} +) { + var pendingPaste: CharSequence? = null + + host.childFragmentManager.setFragmentResultListener(RecoveryKeyPasteWarningFragment.REQUEST_KEY, host.viewLifecycleOwner) { _, bundle -> + if (bundle.getBoolean(RecoveryKeyPasteWarningFragment.REQUEST_KEY)) { + pendingPaste?.let { insertText(it) } + } + pendingPaste = null + onWarningDismissed() + } + + setOnPasteListener { pasteText -> + if (RecoveryKeyDetector.containsRecoveryKey(pasteText?.toString(), SignalStore.account.accountEntropyPoolOrNull)) { + pendingPaste = pasteText + onWarningShown() + RecoveryKeyPasteWarningFragment().show(host.childFragmentManager, null) + true + } else { + false + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteWarningFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteWarningFragment.kt new file mode 100644 index 0000000000..164c656b1c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteWarningFragment.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.warning + +import android.app.Dialog +import android.content.DialogInterface +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.fragment.app.setFragmentResult +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.ComposeFullScreenDialogFragment + +/** + * Displayed via the [org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsFragment] whenever the user + * attempts to paste their recovery key into the input field. + * + * A result is always delivered to [REQUEST_KEY] when this fragment is dismissed, with the boolean + * indicating whether the user chose to proceed with the paste. The host can rely on this firing for + * every dismissal path (paste, decline, or cancel) to restore its own state. + */ +class RecoveryKeyPasteWarningFragment : ComposeFullScreenDialogFragment() { + + companion object { + const val REQUEST_KEY = "recovery_key_request" + } + + private var shouldPaste = false + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + window?.setWindowAnimations(0) + } + } + + override fun onDismiss(dialog: DialogInterface) { + setFragmentResult( + REQUEST_KEY, + Bundle().apply { + putBoolean(REQUEST_KEY, shouldPaste) + } + ) + + super.onDismiss(dialog) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun DialogContent() { + var isDisplayingFinalWarningDialog by remember { mutableStateOf(false) } + + val eventHandler: (RecoveryKeyWarningSheetEvent) -> Unit = { + when (it) { + RecoveryKeyWarningSheetEvent.DoNotShareClick -> { + dismissAllowingStateLoss() + } + + RecoveryKeyWarningSheetEvent.GotItClick -> error("Not supported for paste") + RecoveryKeyWarningSheetEvent.LearnMoreClick -> error("Not supported for paste") + RecoveryKeyWarningSheetEvent.PasteKeyClick -> { + shouldPaste = true + dismissAllowingStateLoss() + } + + RecoveryKeyWarningSheetEvent.ShareKeyClick -> { + isDisplayingFinalWarningDialog = true + } + } + } + + if (isDisplayingFinalWarningDialog) { + RecoveryKeyWarningDialog( + events = eventHandler + ) + } else { + ModalBottomSheet( + onDismissRequest = { dismissAllowingStateLoss() }, + dragHandle = { BottomSheets.Handle() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + RecoveryKeyWarningSheetContent( + clipStage = ClipStage.PASTE, + events = eventHandler + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetContent.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetContent.kt new file mode 100644 index 0000000000..b564a0415b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetContent.kt @@ -0,0 +1,185 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.warning + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.horizontalGutters +import org.thoughtcrime.securesms.R + +@Composable +fun RecoveryKeyWarningSheetContent( + clipStage: ClipStage, + events: (RecoveryKeyWarningSheetEvent) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.horizontalGutters(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_warning_40), + tint = MaterialTheme.colorScheme.error, + contentDescription = null, + modifier = Modifier + .padding(top = 20.dp, bottom = 16.dp) + .size(80.dp) + .background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape) + .padding(20.dp) + ) + + Text( + text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_your_recovery_key), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 12.dp) + ) + + val signalWillNeverMessageYou = stringResource(R.string.RecoveryKeyWarningSheetContent__signal_will_never_message_you) + val recoveryKeyWarningBody = stringResource(R.string.RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond) + + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(signalWillNeverMessageYou) + } + + append(" ") + append(recoveryKeyWarningBody) + }, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 75.dp) + ) + + when (clipStage) { + ClipStage.COPY -> CopyActionButtons(events = events) + ClipStage.PASTE -> PasteActionButtons(events = events) + } + + Spacer(modifier = Modifier.size(16.dp)) + } +} + +@Composable +fun CopyActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) { + Buttons.LargeTonal(onClick = { + events(RecoveryKeyWarningSheetEvent.GotItClick) + }) { + Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__got_it)) + } + + TextButton(onClick = { + events(RecoveryKeyWarningSheetEvent.LearnMoreClick) + }) { + Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more)) + } +} + +@Composable +fun PasteActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) { + Buttons.LargeTonal(onClick = { + events(RecoveryKeyWarningSheetEvent.DoNotShareClick) + }) { + Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_key)) + } + + TextButton(onClick = { + events(RecoveryKeyWarningSheetEvent.ShareKeyClick) + }) { + Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__share_key)) + } +} + +@Composable +fun RecoveryKeyWarningDialog(events: (RecoveryKeyWarningSheetEvent) -> Unit) { + val bodyIntro = stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_your_recovery_key_with_anyone) + val bodyEmphasis = stringResource(R.string.RecoveryKeyWarningDialog__signal_will_never_message_you_for_your_recovery_key) + val bodyOutro = stringResource(R.string.RecoveryKeyWarningDialog__never_respond_to_a_chat) + + Dialogs.SimpleAlertDialog( + title = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_recovery_key)), + body = buildAnnotatedString { + append(bodyIntro) + append(" ") + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(bodyEmphasis) + } + + append(" ") + append(bodyOutro) + }, + confirm = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__paste_key)), + confirmColor = MaterialTheme.colorScheme.error, + dismiss = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__dont_share)), + onConfirm = { events(RecoveryKeyWarningSheetEvent.PasteKeyClick) }, + onDeny = { events(RecoveryKeyWarningSheetEvent.DoNotShareClick) } + ) +} + +enum class ClipStage { + COPY, + PASTE +} + +@DayNightPreviews +@Composable +private fun RecoveryKeyWarningSheetContentCopyPreview() { + Previews.BottomSheetPreview { + RecoveryKeyWarningSheetContent( + clipStage = ClipStage.COPY, + events = {}, + modifier = Modifier.fillMaxSize() + ) + } +} + +@DayNightPreviews +@Composable +private fun RecoveryKeyWarningSheetContentPastePreview() { + Previews.BottomSheetPreview { + RecoveryKeyWarningSheetContent( + clipStage = ClipStage.PASTE, + events = {}, + modifier = Modifier.fillMaxSize() + ) + } +} + +@DayNightPreviews +@Composable +private fun RecoveryKeyWarningDialogPreview() { + Previews.Preview { + RecoveryKeyWarningDialog(events = {}) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetEvent.kt new file mode 100644 index 0000000000..7a61317dbb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.warning + +sealed interface RecoveryKeyWarningSheetEvent { + data object DoNotShareClick : RecoveryKeyWarningSheetEvent + data object ShareKeyClick : RecoveryKeyWarningSheetEvent + data object PasteKeyClick : RecoveryKeyWarningSheetEvent + data object GotItClick : RecoveryKeyWarningSheetEvent + data object LearnMoreClick : RecoveryKeyWarningSheetEvent +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java index 5c781cadce..80593a9fd9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.components; +import android.content.ClipData; import android.content.Context; import android.content.res.Configuration; import android.graphics.Canvas; @@ -19,6 +20,7 @@ import android.view.Menu; import android.view.MenuItem; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputConnectionWrapper; import androidx.annotation.IdRes; import androidx.annotation.NonNull; @@ -26,6 +28,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.core.content.ContextCompat; +import org.signal.core.util.ServiceUtil; import org.signal.core.util.StringUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; @@ -68,6 +71,7 @@ public class ComposeText extends EmojiEditText { @Nullable private CursorPositionChangedListener cursorPositionChangedListener; @Nullable private InlineQueryChangedListener inlineQueryChangedListener; @Nullable private StylingChangedListener stylingChangedListener; + @Nullable private OnPasteListener onPasteListener; public ComposeText(Context context) { super(context); @@ -213,6 +217,41 @@ public class ComposeText extends EmojiEditText { stylingChangedListener = listener; } + public void setOnPasteListener(@Nullable OnPasteListener listener) { + onPasteListener = listener; + } + + /** + * Inserts the given text at the current selection (replacing any selected text), as if pasted. + * This goes directly through the underlying {@link Editable}, so it does not pass through the + * {@link OnPasteListener}. Used to complete a paste the listener previously intercepted, replaying + * the exact text that was intercepted rather than re-reading the clipboard — the intercepted text + * may have come from an IME suggestion (e.g. the keyboard's clipboard chip) that is not the + * current clipboard contents. + */ + public void insertText(@NonNull CharSequence text) { + Editable editable = getText(); + if (editable == null) { + return; + } + + int selectionStart = getSelectionStart(); + int selectionEnd = getSelectionEnd(); + + int start; + int end; + if (selectionStart < 0 || selectionEnd < 0) { + start = editable.length(); + end = editable.length(); + } else { + start = Math.min(selectionStart, selectionEnd); + end = Math.max(selectionStart, selectionEnd); + } + + editable.replace(start, end, text); + setSelection(start + text.length()); + } + private boolean isLandscape() { return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; } @@ -242,7 +281,19 @@ public class ComposeText extends EmojiEditText { editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; } - return inputConnection; + if (inputConnection == null) { + return null; + } + + return new InputConnectionWrapper(inputConnection, true) { + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + if (onPasteListener != null && text != null && onPasteListener.onPaste(text)) { + return true; + } + return super.commitText(text, newCursorPosition); + } + }; } public boolean hasMentions() { @@ -479,6 +530,20 @@ public class ComposeText extends EmojiEditText { return true; } + @Override + public boolean onTextContextMenuItem(int id) { + if ((id == android.R.id.paste || id == android.R.id.pasteAsPlainText) && onPasteListener != null) { + ClipData clipData = ServiceUtil.getClipboardManager(getContext()).getPrimaryClip(); + CharSequence pasteText = clipData != null && clipData.getItemCount() > 0 ? clipData.getItemAt(0).coerceToText(getContext()) : null; + + if (onPasteListener.onPaste(pasteText)) { + return true; + } + } + + return super.onTextContextMenuItem(id); + } + /** * Return true if we think the user may be inputting a time. */ @@ -576,4 +641,15 @@ public class ComposeText extends EmojiEditText { public interface StylingChangedListener { void onStylingChanged(); } + + public interface OnPasteListener { + /** + * Invoked before a paste is applied to the field, giving an observer the chance to intercept it. + * + * @param pasteText the text currently on the clipboard, or {@code null} if it could not be read + * @return true to consume the paste (the listener will handle it, e.g. by prompting the user), + * or false to let the paste proceed normally + */ + boolean onPaste(@Nullable CharSequence pasteText); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 6d3c882540..e188688036 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -143,6 +143,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.AttachmentSaver import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.backup.v2.ui.subscription.BackupUpgradeAvailabilityChecker +import org.thoughtcrime.securesms.backup.v2.ui.warning.guardAgainstRecoveryKeyPaste import org.thoughtcrime.securesms.badges.gifts.OpenableGift import org.thoughtcrime.securesms.badges.gifts.OpenableGiftItemDecoration import org.thoughtcrime.securesms.badges.gifts.viewgift.received.ViewReceivedGiftBottomSheet @@ -1301,6 +1302,7 @@ class ConversationFragment : addTextChangedListener(composeTextEventsListener) setStylingChangedListener(composeTextEventsListener) setOnClickListener(composeTextEventsListener) + guardAgainstRecoveryKeyPaste(this@ConversationFragment) filters += ByteLimitInputFilter(MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES) } 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 e6e2d519e4..0edccc4d6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -146,6 +146,15 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) } } + /** + * The locally-stored [AccountEntropyPool], or null if one has not yet been generated or restored. + * Unlike [accountEntropyPool], reading this never generates and persists a new AEP as a side effect. + */ + val accountEntropyPoolOrNull: AccountEntropyPool? + get() = AEP_LOCK.withLock { + getString(KEY_ACCOUNT_ENTROPY_POOL, null)?.let { AccountEntropyPool(it) } + } + fun rotateAccountEntropyPool(aep: AccountEntropyPool) { AEP_LOCK.withLock { Log.i(TAG, "Rotating Account Entropy Pool (AEP)...", Throwable(), true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt index ff9ecb0148..bac5bb4b54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt @@ -20,6 +20,7 @@ import org.signal.core.ui.view.Stub import org.signal.core.util.ByteLimitInputFilter import org.signal.core.util.EditTextUtil import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.ui.warning.guardAgainstRecoveryKeyPaste import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment import org.thoughtcrime.securesms.components.ViewBinderDelegate @@ -71,6 +72,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a private lateinit var inlineQueryResultsController: InlineQueryResultsController private var requestedEmojiDrawer: Boolean = false + private var displayingRecoveryKeyWarning: Boolean = false private var recipient: Recipient? = null @@ -97,6 +99,14 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a binding.content.addAMessageInput.setText(requireArguments().getCharSequence(ARG_INITIAL_TEXT)) binding.content.addAMessageInput.addTextChangedListener { viewModel.setMessage(it) } binding.content.addAMessageInput.filters += ByteLimitInputFilter(MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES) + binding.content.addAMessageInput.guardAgainstRecoveryKeyPaste( + host = this, + onWarningShown = { displayingRecoveryKeyWarning = true }, + onWarningDismissed = { + displayingRecoveryKeyWarning = false + ViewUtil.focusAndShowKeyboard(binding.content.addAMessageInput) + } + ) binding.content.emojiToggle.setOnClickListener { onEmojiToggleClicked() } if (requireArguments().getBoolean(ARG_INITIAL_EMOJI_TOGGLE) && view is KeyboardAwareLinearLayout) { @@ -161,7 +171,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a } override fun onKeyboardHidden() { - if (!requestedEmojiDrawer) { + if (!requestedEmojiDrawer && !displayingRecoveryKeyWarning) { super.onKeyboardHidden() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee6914b797..e53424468c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ https://support.signal.org/hc/articles/360031949872#pending https://support.signal.org/hc/articles/360031949872#donate https://support.signal.org/hc/articles/9021007554074 + https://support.signal.org/hc/articles/9932566320410 https://play.google.com/store/account/subscriptions?sku=%1$s&package=%2$s @@ -9935,5 +9936,33 @@ Open account settings + + Do not share your recovery key + + Signal will never message you + + for your recovery key. Never respond to a chat pretending to be Signal. Store your recovery key somewhere safe and never share it with anyone. + + Got it + + Learn more + + Do not share key + + Share key + + + Do not share recovery key + + Do not share your recovery key with anyone. This will let them take over your account. + + Signal will never message you for your recovery key. + + Never respond to a chat pretending to be Signal. + + Paste key + + Don\'t share + diff --git a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetectorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetectorTest.kt new file mode 100644 index 0000000000..e884c48fa3 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetectorTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.warning + +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.junit.Test +import org.signal.core.models.AccountEntropyPool + +class RecoveryKeyDetectorTest { + + private val recoveryKey = AccountEntropyPool.generate() + + @Test + fun `null recovery key is never detected`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.value, null)).isFalse() + } + + @Test + fun `null or blank text is not a recovery key`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey(null, recoveryKey)).isFalse() + assertThat(RecoveryKeyDetector.containsRecoveryKey("", recoveryKey)).isFalse() + assertThat(RecoveryKeyDetector.containsRecoveryKey(" ", recoveryKey)).isFalse() + } + + @Test + fun `ordinary message is not a recovery key`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey("Hey, are we still on for lunch tomorrow?", recoveryKey)).isFalse() + } + + @Test + fun `text shorter than a key is not a recovery key`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.value.dropLast(1), recoveryKey)).isFalse() + } + + @Test + fun `exact storage form is detected`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.value, recoveryKey)).isTrue() + } + + @Test + fun `uppercase form is detected`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.value.uppercase(), recoveryKey)).isTrue() + } + + @Test + fun `display form with substitution characters is detected`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.displayValue, recoveryKey)).isTrue() + } + + @Test + fun `grouped with spaces is detected`() { + val grouped = recoveryKey.value.chunked(4).joinToString(" ") + assertThat(RecoveryKeyDetector.containsRecoveryKey(grouped, recoveryKey)).isTrue() + } + + @Test + fun `embedded in surrounding text is detected`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey("Hey, here is my recovery key: ${recoveryKey.value} keep it safe!", recoveryKey)).isTrue() + } + + @Test + fun `embedded grouped display form is detected`() { + val grouped = recoveryKey.displayValue.chunked(4).joinToString(" ") + assertThat(RecoveryKeyDetector.containsRecoveryKey("my key\n$grouped\nthanks", recoveryKey)).isTrue() + } + + @Test + fun `a different valid recovery key is not detected`() { + val otherKey = AccountEntropyPool.generate() + assertThat(RecoveryKeyDetector.containsRecoveryKey(otherKey.value, recoveryKey)).isFalse() + } + + // These would be false positives under shape-only matching (charset + length, no checksum). Matching + // against the user's actual key rejects them. + + @Test + fun `64 in-alphabet characters that are not the key are not detected`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey("g".repeat(64), recoveryKey)).isFalse() + } + + @Test + fun `64 char sha256 hex digest is not detected`() { + assertThat(RecoveryKeyDetector.containsRecoveryKey("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", recoveryKey)).isFalse() + } +}