Add warnings for phishing.
This commit is contained in:
parent
5909a1b92a
commit
029b91066f
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 = {})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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&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>
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user