Fix local backup restore v2 in regV5.

This commit is contained in:
Greyson Parrelli
2026-07-08 14:44:07 -04:00
committed by Michelle Tang
parent 16fe25a38a
commit 8ffbfb4000
7 changed files with 147 additions and 5 deletions
@@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.LinkedDeviceInfo
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.LocalBackupRestoreMediaJob
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.NewAccount
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
@@ -293,7 +294,8 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
try {
if (!FullBackupImporter.validatePassphrase(context, uri, passphrase)) {
emit(LocalBackupRestoreProgress.Error(IllegalArgumentException("Invalid passphrase")))
Log.w(TAG, "V1 restore failed: incorrect passphrase")
emit(LocalBackupRestoreProgress.IncorrectCredential)
return@flow
}
@@ -346,8 +348,27 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
val messageBackupKey = aep.deriveMessageBackupKey()
val snapshotFileSystem = SnapshotFileSystem(context, backupDir)
if (!LocalArchiver.canDecryptMainArchive(snapshotFileSystem, messageBackupKey)) {
Log.w(TAG, "V2 restore failed: recovery key cannot decrypt backup")
emit(LocalBackupRestoreProgress.IncorrectCredential)
return@flow
}
when (val result = LocalArchiver.import(snapshotFileSystem, selfData, messageBackupKey)) {
is Result.Success -> {
AppDependencies.jobManager.add(LocalBackupRestoreMediaJob.create(rootUri))
// Only adopt the entered recovery key as the account's AEP if the backup actually belongs to this account.
// Otherwise we'd overwrite the account's real AEP with a foreign backup's key. Messages are still imported.
val actualBackupId = LocalArchiver.getBackupId(snapshotFileSystem, messageBackupKey)
val expectedBackupId = SignalStore.account.accountEntropyPool.deriveMessageBackupKey().deriveBackupId(selfAci)
if (actualBackupId?.value?.contentEquals(expectedBackupId.value) == true) {
Log.i(TAG, "V2 local backup belongs to current account; adopting entered recovery key.")
SignalStore.account.restoreAccountEntropyPool(aep)
updateInProgressRegistrationData { this.accountEntropyPool = aep.value }
} else {
Log.w(TAG, "V2 local backup does not belong to current account; keeping existing recovery key.")
}
emit(LocalBackupRestoreProgress.Complete)
Log.d(TAG, "V2 restore complete.")
}
@@ -100,6 +100,9 @@ fun LocalBackupRestoreScreen(
LocalBackupRestoreState.RestorePhase.InProgress -> {
InProgressContent(progressFraction = state.progressFraction, onEvent = onEvent, modifier = modifier)
}
LocalBackupRestoreState.RestorePhase.IncorrectCredential -> {
IncorrectCredentialContent(backupType = state.backupInfo?.type, onEvent = onEvent, modifier = modifier)
}
LocalBackupRestoreState.RestorePhase.Error -> {
ErrorContent(errorMessage = state.errorMessage, onEvent = onEvent, modifier = modifier)
}
@@ -530,6 +533,41 @@ private fun InProgressContent(
)
}
@Composable
private fun IncorrectCredentialContent(
backupType: LocalBackupInfo.BackupType?,
onEvent: (LocalBackupRestoreEvents) -> Unit,
modifier: Modifier = Modifier
) {
val headline = if (backupType == LocalBackupInfo.BackupType.V1) {
stringResource(R.string.LocalBackupRestoreScreen__incorrect_passphrase)
} else {
stringResource(R.string.LocalBackupRestoreScreen__incorrect_recovery_key)
}
LocalBackupRestoreLayout(
modifier = modifier,
description = {
Description(
headline = headline,
body = stringResource(R.string.LocalBackupRestoreScreen__incorrect_credential_description)
)
},
content = {},
primaryButton = { buttonModifier ->
OutlinedButton(
onClick = { onEvent(LocalBackupRestoreEvents.RestoreBackup) },
modifier = buttonModifier
) {
Text(text = stringResource(R.string.LocalBackupRestoreScreen__try_again))
}
},
secondaryButton = { buttonModifier ->
CancelButton(onEvent, buttonModifier)
}
)
}
@Composable
private fun ErrorContent(
errorMessage: String?,
@@ -671,3 +709,23 @@ private fun LocalBackupRestoreScreenErrorPreview() {
)
}
}
@AllDevicePreviews
@Composable
private fun LocalBackupRestoreScreenIncorrectCredentialPreview() {
Previews.Preview {
LocalBackupRestoreScreen(
state = LocalBackupRestoreState(
restorePhase = LocalBackupRestoreState.RestorePhase.IncorrectCredential,
backupInfo = LocalBackupInfo(
type = LocalBackupInfo.BackupType.V2,
date = LocalDateTime.of(2026, 3, 15, 14, 30, 0),
name = "signal-backup-2026-03-15-14-30-00",
uri = Uri.EMPTY,
sizeBytes = 511.mebiBytes.bytes
)
),
onEvent = {}
)
}
}
@@ -42,6 +42,9 @@ data class LocalBackupRestoreState(
/** Restore is actively in progress. */
InProgress,
/** The entered passphrase/recovery key could not decrypt the backup. */
IncorrectCredential,
/** Restore failed. */
Error
}
@@ -111,10 +111,6 @@ class LocalBackupRestoreViewModel(
}
private suspend fun onRestoreComplete(state: LocalBackupRestoreState) {
if (state.aep != null) {
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepVerified(state.aep))
}
if (isPreRegistration) {
resultBus.sendResult(resultKey, LocalBackupRestoreResult.Success(state.aep))
parentEventEmitter.navigateBack()
@@ -187,6 +183,10 @@ class LocalBackupRestoreViewModel(
onRestoreComplete(_localState.value.copy(aep = currentState.aep, v1Passphrase = currentState.v1Passphrase))
_localState.value
}
is LocalBackupRestoreProgress.IncorrectCredential -> {
Log.w(TAG, "Restore failed: incorrect passphrase/recovery key")
currentState.copy(restorePhase = LocalBackupRestoreState.RestorePhase.IncorrectCredential)
}
is LocalBackupRestoreProgress.Error -> {
Log.w(TAG, "Restore failed", progress.cause)
LocalBackupRestoreState(
@@ -236,6 +236,12 @@
<string name="LocalBackupRestoreScreen__restore_failed_description">An error occurred while restoring your backup. Please try again.</string>
<!-- Button to retry restore after failure -->
<string name="LocalBackupRestoreScreen__try_again">Try again</string>
<!-- Title shown when the entered passphrase cannot decrypt the selected backup -->
<string name="LocalBackupRestoreScreen__incorrect_passphrase">Incorrect passphrase</string>
<!-- Title shown when the entered recovery key cannot decrypt the selected backup -->
<string name="LocalBackupRestoreScreen__incorrect_recovery_key">Incorrect recovery key</string>
<!-- Description shown when the entered passphrase or recovery key cannot decrypt the selected backup -->
<string name="LocalBackupRestoreScreen__incorrect_credential_description">The credential you entered doesn\'t match this backup. Please check it and try again.</string>
<!-- Button to link an existing Signal account to this device -->
<string name="WelcomeScreen__link_your_account">Link your account</string>
<!-- Prompt shown to users who don\'t have a Signal account yet -->
@@ -22,6 +22,7 @@ import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
@@ -270,4 +271,54 @@ class LocalBackupRestoreViewModelTest {
coVerify { mockRepository.restoreAccountRecord(any()) }
assertThat(emittedParentEvents).contains(RegistrationFlowEvent.RegistrationComplete)
}
// ==================== Incorrect Credential Tests ====================
@Test
fun `V1 restore with incorrect passphrase surfaces IncorrectCredential and does not complete`() = runTest(testDispatcher) {
val viewModel = createViewModel(isPreRegistration = false)
backgroundScope.launch { viewModel.state.collect {} }
val backupInfo = LocalBackupInfo(
type = LocalBackupInfo.BackupType.V1,
date = LocalDateTime.now(),
name = "backup.backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo)
every { mockRepository.restoreV1Backup(any(), any()) } returns flowOf(LocalBackupRestoreProgress.IncorrectCredential)
viewModel.applyEvent(initialState, LocalBackupRestoreEvents.PassphraseSubmitted("passphrase"), stateEmitter)
assertThat(viewModel.state.value.restorePhase).isEqualTo(LocalBackupRestoreState.RestorePhase.IncorrectCredential)
assertThat(emittedParentEvents).isEmpty()
coVerify(exactly = 0) { mockRepository.setRestoreDecision(any()) }
}
@Test
fun `V2 restore with incorrect recovery key surfaces IncorrectCredential and does not complete`() = runTest(testDispatcher) {
val viewModel = createViewModel(isPreRegistration = false)
backgroundScope.launch { viewModel.state.collect {} }
val backupInfo = LocalBackupInfo(
type = LocalBackupInfo.BackupType.V2,
date = LocalDateTime.now(),
name = "signal-backup",
uri = mockk()
)
val initialState = LocalBackupRestoreState(backupInfo = backupInfo, selectedFolderUri = mockk())
every { mockRepository.restoreV2Backup(any(), any(), any()) } returns flowOf(LocalBackupRestoreProgress.IncorrectCredential)
viewModel.applyEvent(initialState, LocalBackupRestoreEvents.PassphraseSubmitted(VALID_AEP), stateEmitter)
assertThat(viewModel.state.value.restorePhase).isEqualTo(LocalBackupRestoreState.RestorePhase.IncorrectCredential)
assertThat(emittedParentEvents).isEmpty()
coVerify(exactly = 0) { mockRepository.setRestoreDecision(any()) }
}
companion object {
private const val VALID_AEP = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"
}
}
@@ -25,6 +25,9 @@ sealed interface LocalBackupRestoreProgress {
/** The restore completed successfully. */
data object Complete : LocalBackupRestoreProgress
/** The provided passphrase (V1) or recovery key (V2) could not decrypt the backup. */
data object IncorrectCredential : LocalBackupRestoreProgress
/** The restore failed with an error. */
data class Error(val cause: Throwable) : LocalBackupRestoreProgress
}