Add warnings for phishing.

This commit is contained in:
Alex Hart 2026-06-09 15:14:53 -03:00 committed by Cody Henthorne
parent 5909a1b92a
commit 029b91066f
12 changed files with 649 additions and 3 deletions

View File

@ -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)

View File

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

View File

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

View File

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

View File

@ -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 = {})
}
}

View File

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

View File

@ -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);
}
}

View File

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

View File

@ -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)

View File

@ -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()
}
}

View File

@ -25,6 +25,7 @@
<string name="pending_transfer_url" translatable="false">https://support.signal.org/hc/articles/360031949872#pending</string>
<string name="donate_faq_url" translatable="false">https://support.signal.org/hc/articles/360031949872#donate</string>
<string name="inactive_primary_support" translatable="false">https://support.signal.org/hc/articles/9021007554074</string>
<string name="recovery_key_phishing_support_url" translatable="false">https://support.signal.org/hc/articles/9932566320410</string>
<!-- First placeholder is productId, second placeholder is app package -->
<string name="backup_subscription_management_url">https://play.google.com/store/account/subscriptions?sku=%1$s&amp;package=%2$s</string>
@ -9935,5 +9936,33 @@
<!-- Button on the safety tips screen that opens account settings -->
<string name="SafetyTipsBottomSheet__open_account_settings">Open account settings</string>
<!-- Title of a warning sheet shown when the user copies or pastes their recovery key, urging them not to share it -->
<string name="RecoveryKeyWarningSheetContent__do_not_share_your_recovery_key">Do not share your recovery key</string>
<!-- Bolded lead-in to the warning sheet body; combined with RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond to form a full sentence -->
<string name="RecoveryKeyWarningSheetContent__signal_will_never_message_you">Signal will never message you</string>
<!-- Remainder of the warning sheet body following the bolded lead-in RecoveryKeyWarningSheetContent__signal_will_never_message_you -->
<string name="RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond">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.</string>
<!-- Button dismissing the recovery key warning sheet after the user has copied their key -->
<string name="RecoveryKeyWarningSheetContent__got_it">Got it</string>
<!-- Button opening a help article with more information about recovery key safety -->
<string name="RecoveryKeyWarningSheetContent__learn_more">Learn more</string>
<!-- Button declining to paste/share the recovery key, dismissing the warning sheet -->
<string name="RecoveryKeyWarningSheetContent__do_not_share_key">Do not share key</string>
<!-- Button indicating the user wants to proceed with sharing/pasting the recovery key, which shows a final confirmation -->
<string name="RecoveryKeyWarningSheetContent__share_key">Share key</string>
<!-- Title of the final confirmation dialog shown before pasting a recovery key -->
<string name="RecoveryKeyWarningDialog__do_not_share_recovery_key">Do not share recovery key</string>
<!-- First sentence of the final confirmation dialog body warning against sharing the recovery key -->
<string name="RecoveryKeyWarningDialog__do_not_share_your_recovery_key_with_anyone">Do not share your recovery key with anyone. This will let them take over your account.</string>
<!-- Bolded middle sentence of the final confirmation dialog body -->
<string name="RecoveryKeyWarningDialog__signal_will_never_message_you_for_your_recovery_key">Signal will never message you for your recovery key.</string>
<!-- Final sentence of the final confirmation dialog body -->
<string name="RecoveryKeyWarningDialog__never_respond_to_a_chat">Never respond to a chat pretending to be Signal.</string>
<!-- Button confirming the user wants to paste the recovery key -->
<string name="RecoveryKeyWarningDialog__paste_key">Paste key</string>
<!-- Button declining to paste the recovery key -->
<string name="RecoveryKeyWarningDialog__dont_share">Don\'t share</string>
<!-- EOF -->
</resources>

View File

@ -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()
}
}