diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt index 9c46244872..4a6fa26971 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt @@ -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.") } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreScreen.kt index 72502bc20d..3cb7ade114 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreState.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreState.kt index c7126b97ee..0c76a78a3f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreState.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt index 56c45ad5f6..20d5679e63 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt @@ -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( diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 1d5bc3e874..568616f082 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -236,6 +236,12 @@ An error occurred while restoring your backup. Please try again. Try again + + Incorrect passphrase + + Incorrect recovery key + + The credential you entered doesn\'t match this backup. Please check it and try again. Link your account diff --git a/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt index bb440a3606..82cf5c6bcf 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt @@ -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" + } } diff --git a/lib/archive/src/main/java/org/signal/archive/LocalBackupRestoreProgress.kt b/lib/archive/src/main/java/org/signal/archive/LocalBackupRestoreProgress.kt index 37449c6567..6ac6f7676d 100644 --- a/lib/archive/src/main/java/org/signal/archive/LocalBackupRestoreProgress.kt +++ b/lib/archive/src/main/java/org/signal/archive/LocalBackupRestoreProgress.kt @@ -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 }