Update sad paths around manual backup key restore.

This commit is contained in:
Cody Henthorne
2025-01-27 14:53:26 -05:00
committed by Greyson Parrelli
parent b5f323d4af
commit 139b62e469
11 changed files with 257 additions and 57 deletions

View File

@@ -37,7 +37,7 @@ object IBANVisualTransformation : VisualTransformation {
}
override fun transformedToOriginal(offset: Int): Int {
return offset - (offset / 4)
return offset - (offset / 5)
}
}
}

View File

@@ -85,6 +85,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
private const val KEY_HAS_LINKED_DEVICES = "account.has_linked_devices"
private const val KEY_ACCOUNT_ENTROPY_POOL = "account.account_entropy_pool"
private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY = "account.restored_account_entropy_pool"
private val AEP_LOCK = ReentrantLock()
}
@@ -140,16 +141,28 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
fun restoreAccountEntropyPool(aep: AccountEntropyPool) {
AEP_LOCK.withLock {
store.beginWrite().putString(KEY_ACCOUNT_ENTROPY_POOL, aep.value).commit()
store
.beginWrite()
.putString(KEY_ACCOUNT_ENTROPY_POOL, aep.value)
.putBoolean(KEY_RESTORED_ACCOUNT_ENTROPY_KEY, true)
.commit()
}
}
fun resetAccountEntropyPool() {
AEP_LOCK.withLock {
store.beginWrite().putString(KEY_ACCOUNT_ENTROPY_POOL, null).commit()
Log.i(TAG, "Resetting Account Entropy Pool (AEP)", Throwable())
store
.beginWrite()
.putString(KEY_ACCOUNT_ENTROPY_POOL, null)
.putBoolean(KEY_RESTORED_ACCOUNT_ENTROPY_KEY, false)
.commit()
}
}
@get:Synchronized
val restoredAccountEntropyPool by booleanValue(KEY_RESTORED_ACCOUNT_ENTROPY_KEY, false)
/** The local user's [ACI]. */
val aci: ACI?
get() = ACI.parseOrNull(getString(KEY_ACI, null))

View File

@@ -19,7 +19,6 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
private const val SVR2_AUTH_TOKENS = "kbs.kbs_auth_tokens"
private const val SVR_LAST_AUTH_REFRESH_TIMESTAMP = "kbs.kbs_auth_tokens.last_refresh_timestamp"
private const val SVR3_AUTH_TOKENS = "kbs.svr3_auth_tokens"
private const val RESTORED_VIA_ACCOUNT_ENTROPY_KEY = "kbs.restore_via_account_entropy_pool"
private const val INITIAL_RESTORE_MASTER_KEY = "kbs.initialRestoreMasterKey"
}
@@ -145,7 +144,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
@Synchronized
fun hasOptedInWithAccess(): Boolean {
return hasPin() || restoredViaAccountEntropyPool
return hasPin() || SignalStore.account.restoredAccountEntropyPool
}
@Synchronized
@@ -153,9 +152,6 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
return localPinHash != null
}
@get:Synchronized
val restoredViaAccountEntropyPool by booleanValue(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, false)
@get:Synchronized
@set:Synchronized
var isPinForgottenOrSkipped: Boolean by booleanValue(PIN_FORGOTTEN_OR_SKIPPED, false)
@@ -242,7 +238,6 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
.putBoolean(OPTED_OUT, true)
.remove(LOCK_LOCAL_PIN_HASH)
.remove(PIN)
.remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY)
.putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
.commit()
}

View File

@@ -19,7 +19,7 @@ public class LogSectionPin implements LogSection {
.append("Next Reminder Interval: ").append(SignalStore.pin().getCurrentInterval()).append("\n")
.append("Reglock: ").append(SignalStore.svr().isRegistrationLockEnabled()).append("\n")
.append("Signal PIN: ").append(SignalStore.svr().hasPin()).append("\n")
.append("Restored via AEP: ").append(SignalStore.svr().getRestoredViaAccountEntropyPool()).append("\n")
.append("Restored via AEP: ").append(SignalStore.account().getRestoredAccountEntropyPool()).append("\n")
.append("Opted Out: ").append(SignalStore.svr().hasOptedOut()).append("\n")
.append("Last Creation Failed: ").append(SignalStore.svr().lastPinCreateFailed()).append("\n")
.append("Needs Account Restore: ").append(SignalStore.storageService().getNeedsAccountRestore()).append("\n")

View File

@@ -13,14 +13,13 @@ import androidx.compose.ui.text.input.VisualTransformation
/**
* Visual formatter for backup keys.
*
* @param length max length of key
* @param chunkSize character count per group
*/
class BackupKeyVisualTransformation(private val length: Int, private val chunkSize: Int) : VisualTransformation {
class BackupKeyVisualTransformation(private val chunkSize: Int) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
var output = ""
for (i in text.take(length).indices) {
output += text[i]
for ((i, c) in text.withIndex()) {
output += c
if (i % chunkSize == chunkSize - 1) {
output += " "
}
@@ -38,7 +37,7 @@ class BackupKeyVisualTransformation(private val length: Int, private val chunkSi
}
override fun transformedToOriginal(offset: Int): Int {
return offset - (offset / chunkSize)
return offset - (offset / (chunkSize + 1))
}
}
}

View File

@@ -6,6 +6,9 @@
package org.thoughtcrime.securesms.registrationv3.ui.restore
import android.graphics.Typeface
import android.os.Bundle
import android.view.View
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -18,6 +21,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
@@ -36,6 +40,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
@@ -47,17 +52,24 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
@@ -72,29 +84,50 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class EnterBackupKeyFragment : ComposeFragment() {
companion object {
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752-Backup-and-Restore-Messages"
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752"
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val viewModel by viewModels<EnterBackupKeyViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
sharedViewModel
.state
.map { it.registerAccountError }
.filterNotNull()
.collect {
sharedViewModel.registerAccountErrorShown()
viewModel.handleRegistrationFailure(it)
}
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state
val state by viewModel.state.collectAsStateWithLifecycle()
val sharedState by sharedViewModel.state.collectAsStateWithLifecycle()
EnterBackupKeyScreen(
backupKey = viewModel.backupKey,
state = state,
sharedState = sharedState,
onBackupKeyChanged = viewModel::updateBackupKey,
onNextClicked = {
viewModel.registering()
sharedViewModel.registerWithBackupKey(
context = requireContext(),
backupKey = state.backupKey,
backupKey = viewModel.backupKey,
e164 = null,
pin = null
)
},
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onSkip = { findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) }
)
@@ -104,9 +137,12 @@ class EnterBackupKeyFragment : ComposeFragment() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EnterBackupKeyScreen(
backupKey: String,
state: EnterBackupKeyState,
sharedState: RegistrationState,
onBackupKeyChanged: (String) -> Unit = {},
onRegistrationErrorDismiss: () -> Unit = {},
onBackupKeyHelp: () -> Unit = {},
onNextClicked: () -> Unit = {},
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
@@ -137,26 +173,38 @@ private fun EnterBackupKeyScreen(
)
}
Buttons.LargeTonal(
enabled = state.backupKeyValid && !sharedState.inProgress,
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.RegistrationActivity_next)
)
AnimatedContent(
targetState = state.isRegistering,
label = "next-progress"
) { isRegistering ->
if (isRegistering) {
CircularProgressIndicator(
modifier = Modifier.size(40.dp)
)
} else {
Buttons.LargeTonal(
enabled = state.backupKeyValid && state.aepValidationError == null,
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.RegistrationActivity_next)
)
}
}
}
}
}
) {
val focusRequester = remember { FocusRequester() }
val visualTransform = remember(state.length, state.chunkLength) { BackupKeyVisualTransformation(length = state.length, chunkSize = state.chunkLength) }
val visualTransform = remember(state.chunkLength) { BackupKeyVisualTransformation(chunkSize = state.chunkLength) }
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = state.backupKey,
value = backupKey,
onValueChange = onBackupKeyChanged,
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
},
onValueChange = onBackupKeyChanged,
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily(typeface = Typeface.MONOSPACE),
lineHeight = 36.sp
@@ -168,8 +216,15 @@ private fun EnterBackupKeyScreen(
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onNext = { if (state.backupKeyValid) onNextClicked() }
onNext = {
if (state.backupKeyValid) {
keyboardController?.hide()
onNextClicked()
}
}
),
supportingText = { state.aepValidationError?.ValidationErrorMessage() },
isError = state.aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
@@ -201,6 +256,40 @@ private fun EnterBackupKeyScreen(
)
}
}
if (state.showRegistrationError) {
if (state.registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
onConfirm = {},
onDeny = onBackupKeyHelp,
onDismiss = onRegistrationErrorDismiss
)
} else {
val message = when (state.registerAccountResult) {
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
}
}
}
}
@Composable
private fun EnterBackupKeyViewModel.AEPValidationError.ValidationErrorMessage() {
when (this) {
is EnterBackupKeyViewModel.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
EnterBackupKeyViewModel.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
EnterBackupKeyViewModel.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@@ -209,7 +298,20 @@ private fun EnterBackupKeyScreen(
private fun EnterBackupKeyScreenPreview() {
Previews.Preview {
EnterBackupKeyScreen(
state = EnterBackupKeyState(backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", length = 64, chunkLength = 4),
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
state = EnterBackupKeyState(requiredLength = 64, chunkLength = 4),
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
)
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenErrorPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
state = EnterBackupKeyState(requiredLength = 64, chunkLength = 4, aepValidationError = EnterBackupKeyViewModel.AEPValidationError.Invalid),
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
)
}

View File

@@ -5,50 +5,125 @@
package org.thoughtcrime.securesms.registrationv3.ui.restore
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.whispersystems.signalservice.api.AccountEntropyPool
class EnterBackupKeyViewModel : ViewModel() {
companion object {
private val INVALID_CHARACTERS = Regex("[^0-9a-zA-Z]")
private val TAG = Log.tag(EnterBackupKeyViewModel::class)
}
private val _state = mutableStateOf(
private val store = MutableStateFlow(
EnterBackupKeyState(
backupKey = "",
length = 64,
requiredLength = 64,
chunkLength = 4
)
)
val state: State<EnterBackupKeyState> = _state
var backupKey by mutableStateOf("")
private set
val state: StateFlow<EnterBackupKeyState> = store
fun updateBackupKey(key: String) {
_state.update {
val newKey = key.removeIllegalCharacters().take(length).lowercase()
copy(backupKey = newKey, backupKeyValid = validate(length, newKey))
val newKey = AccountEntropyPool.removeIllegalCharacters(key).lowercase()
val changed = newKey != backupKey
backupKey = newKey
store.update {
val isValid = validateContents(backupKey)
val isShort = backupKey.length < it.requiredLength
val isExact = backupKey.length == it.requiredLength
var updatedError: AEPValidationError? = checkErrorStillApplies(it.aepValidationError, isShort || isExact, isValid, changed)
if (updatedError == null) {
updatedError = checkForNewError(isShort, isExact, isValid, it.requiredLength)
}
it.copy(backupKeyValid = isValid, aepValidationError = updatedError)
}
}
private fun validate(length: Int, backupKey: String): Boolean {
return backupKey.length == length
private fun validateContents(backupKey: String): Boolean {
return AccountEntropyPool.isFullyValid(backupKey)
}
private fun String.removeIllegalCharacters(): String {
return this.replace(INVALID_CHARACTERS, "")
private fun checkErrorStillApplies(error: AEPValidationError?, isShortOrExact: Boolean, isValid: Boolean, isChanged: Boolean): AEPValidationError? {
return when (error) {
is AEPValidationError.TooLong -> if (isShortOrExact) null else error.copy(count = backupKey.length)
AEPValidationError.Invalid -> if (isValid) null else error
AEPValidationError.Incorrect -> if (isChanged) null else error
null -> null
}
}
private inline fun <T> MutableState<T>.update(update: T.() -> T) {
this.value = this.value.update()
private fun checkForNewError(isShort: Boolean, isExact: Boolean, isValid: Boolean, requiredLength: Int): AEPValidationError? {
return if (!isShort && !isExact) {
AEPValidationError.TooLong(backupKey.length, requiredLength)
} else if (!isValid && isExact) {
AEPValidationError.Invalid
} else {
null
}
}
fun registering() {
store.update { it.copy(isRegistering = true) }
}
fun handleRegistrationFailure(registerAccountResult: RegisterAccountResult) {
store.update {
if (it.isRegistering) {
Log.w(TAG, "Unable to register [${registerAccountResult::class.simpleName}]", registerAccountResult.getCause())
val incorrectKeyError = registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword
if (incorrectKeyError && SignalStore.account.restoredAccountEntropyPool) {
SignalStore.account.resetAccountEntropyPool()
}
it.copy(
isRegistering = false,
showRegistrationError = true,
registerAccountResult = registerAccountResult,
aepValidationError = if (incorrectKeyError) AEPValidationError.Incorrect else it.aepValidationError
)
} else {
it
}
}
}
fun clearRegistrationError() {
store.update {
it.copy(
showRegistrationError = false,
registerAccountResult = null
)
}
}
data class EnterBackupKeyState(
val backupKey: String = "",
val backupKeyValid: Boolean = false,
val length: Int,
val chunkLength: Int
val requiredLength: Int,
val chunkLength: Int,
val isRegistering: Boolean = false,
val showRegistrationError: Boolean = false,
val registerAccountResult: RegisterAccountResult? = null,
val aepValidationError: AEPValidationError? = null
)
sealed interface AEPValidationError {
data class TooLong(val count: Int, val max: Int) : AEPValidationError
data object Invalid : AEPValidationError
data object Incorrect : AEPValidationError
}
}

View File

@@ -104,6 +104,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v
}
private fun navigateToNextScreenViaRestore(userSelection: WelcomeUserSelection) {
sharedViewModel.maybePrefillE164(requireContext())
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
when (userSelection) {

View File

@@ -8063,6 +8063,20 @@
<string name="EnterBackupKey_learn_more">Learn more</string>
<!-- Backup key not known bottom sheet button to skip backup key entry and resume normal reregistration -->
<string name="EnterBackupKey_skip_and_dont_restore">Skip and don\'t restore</string>
<!-- Dialog title shown when entered AEP doesn't work to register with provided e164 -->
<string name="EnterBackupKey_incorrect_backup_key_title">Incorrect backup key</string>
<!-- Dialog message shown when entered AEP doesn't work to register with provided e164 -->
<string name="EnterBackupKey_incorrect_backup_key_message">Make sure you\'re registering with the same phone number and 64-character backup key you saved when enabling Signal backups. Backups can not be recovered without this key.</string>
<!-- Dialog positive button text to try entering their backup key again -->
<string name="EnterBackupKey_try_again">Try again</string>
<!-- Dialog negative button text to get help with backup key -->
<string name="EnterBackupKey_backup_key_help">Backup key help</string>
<!-- Text field error text when the backup key provided has been confirmed invalid -->
<string name="EnterBackupKey_incorrect_backup_key_error">Incorrect backup key</string>
<!-- Text field error text when the backup key is too long -->
<string name="EnterBackupKey_too_long_error">Too long (%1$d/%2$d)</string>
<!-- Text field error when the backup key is invalid -->
<string name="EnterBackupKey_invalid_backup_key_error">Invalid backup key</string>
<!-- Title for restore via qr screen -->
<string name="RestoreViaQr_title">Scan this code with your old phone</string>

View File

@@ -30,6 +30,14 @@ class AccountEntropyPool(val value: String) {
return AccountEntropyPool(stripped)
}
fun isFullyValid(input: String): Boolean {
return LibSignalAccountEntropyPool.isValid(input)
}
fun removeIllegalCharacters(input: String): String {
return input.replace(INVALID_CHARACTERS, "")
}
}
fun deriveMasterKey(): MasterKey {

View File

@@ -1,10 +1,8 @@
package org.whispersystems.signalservice.api.kbs;
import org.signal.libsignal.protocol.kdf.HKDF;
import org.whispersystems.signalservice.api.backup.MessageBackupKey;
import org.signal.core.util.Base64;
import org.whispersystems.signalservice.api.storage.StorageKey;
import org.whispersystems.signalservice.internal.util.Hex;
import org.signal.core.util.Base64;
import org.whispersystems.util.StringUtil;
import java.security.SecureRandom;
@@ -46,11 +44,6 @@ public final class MasterKey {
return derive("Logging Key");
}
public MessageBackupKey deriveMessageBackupKey() {
// TODO [backup] Derive from AEP
return new MessageBackupKey(HKDF.deriveSecrets(masterKey, "20231003_Signal_Backups_GenerateBackupKey".getBytes(), 32));
}
private byte[] derive(String keyName) {
return hmacSha256(masterKey, StringUtil.utf8(keyName));
}