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
}