mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 04:45:14 +01:00
Fix local backup restore v2 in regV5.
This commit is contained in:
committed by
Michelle Tang
parent
16fe25a38a
commit
8ffbfb4000
+22
-1
@@ -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.")
|
||||
}
|
||||
|
||||
+58
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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
|
||||
}
|
||||
|
||||
+4
-4
@@ -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 -->
|
||||
|
||||
+51
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user