diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index c41ed4e97e..e6bb84cab4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -21,6 +21,7 @@ import org.greenrobot.eventbus.EventBus import org.signal.core.models.AccountEntropyPool import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.PNI +import org.signal.core.models.backup.BackupId import org.signal.core.models.backup.MediaName import org.signal.core.models.backup.MediaRootBackupKey import org.signal.core.models.backup.MessageBackupKey @@ -1088,13 +1089,13 @@ object BackupRepository { /** * Imports a local backup file that was exported to disk. */ - fun importLocal(mainStreamFactory: () -> InputStream, mainStreamLength: Long, selfData: SelfData): ImportResult { - val backupKey = SignalStore.backup.messageBackupKey + fun importLocal(mainStreamFactory: () -> InputStream, mainStreamLength: Long, selfData: SelfData, backupId: BackupId, messageBackupKey: MessageBackupKey): ImportResult { + val backupKey = messageBackupKey val frameReader = try { EncryptedBackupReader.createForLocalOrLinking( key = backupKey, - aci = selfData.aci, + backupId = backupId, length = mainStreamLength, dataStream = mainStreamFactory ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt index ed218736ec..22ed0b599b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.backup.v2.local import okio.ByteString.Companion.toByteString import org.signal.core.models.backup.BackupId import org.signal.core.models.backup.MediaName +import org.signal.core.models.backup.MessageBackupKey import org.signal.core.util.Stopwatch import org.signal.core.util.StreamUtil import org.signal.core.util.Util @@ -17,6 +18,7 @@ import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.local.proto.FilesFrame import org.thoughtcrime.securesms.backup.v2.local.proto.Metadata +import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress @@ -152,7 +154,7 @@ object LocalArchiver { /** * Import archive data from a folder on the system. Does not restore attachments. */ - fun import(snapshotFileSystem: SnapshotFileSystem, selfData: BackupRepository.SelfData): RestoreResult { + fun import(snapshotFileSystem: SnapshotFileSystem, selfData: BackupRepository.SelfData, messageBackupKey: MessageBackupKey): RestoreResult { var metadataStream: InputStream? = null try { @@ -169,19 +171,16 @@ object LocalArchiver { return RestoreResult.failure(RestoreFailure.BackupIdMissing) } - val backupId = decryptBackupId(metadata.backupId) - - if (!backupId.value.contentEquals(SignalStore.backup.messageBackupKey.deriveBackupId(SignalStore.account.requireAci()).value)) { - Log.w(TAG, "Local backup metadata backup id does not match derived backup id, likely from another account") - return RestoreResult.failure(RestoreFailure.BackupIdMismatch) - } + val backupId = decryptBackupId(metadata.backupId, messageBackupKey) val mainStreamLength = snapshotFileSystem.mainLength() ?: return ArchiveResult.failure(RestoreFailure.MainStream) BackupRepository.importLocal( mainStreamFactory = { snapshotFileSystem.mainInputStream()!! }, mainStreamLength = mainStreamLength, - selfData = selfData + selfData = selfData, + backupId = backupId, + messageBackupKey = messageBackupKey ) } finally { metadataStream?.close() @@ -190,8 +189,41 @@ object LocalArchiver { return RestoreResult.success(RestoreSuccess.FullSuccess) } - private fun decryptBackupId(encryptedBackupId: Metadata.EncryptedBackupId): BackupId { - val metadataKey = SignalStore.backup.messageBackupKey.deriveLocalBackupMetadataKey() + /** + * Verifies that the provided [messageBackupKey] can decrypt and authenticate the snapshot's main archive. + */ + fun canDecryptMainArchive(snapshotFileSystem: SnapshotFileSystem, messageBackupKey: MessageBackupKey): Boolean { + return try { + val backupId = getBackupId(snapshotFileSystem, messageBackupKey) ?: return false + val mainStreamLength = snapshotFileSystem.mainLength() ?: return false + + EncryptedBackupReader.createForLocalOrLinking( + key = messageBackupKey, + backupId = backupId, + length = mainStreamLength, + dataStream = { snapshotFileSystem.mainInputStream() ?: error("Missing main archive stream") } + ).use { reader -> + reader.getHeader() != null + } + } catch (e: Exception) { + Log.w(TAG, "Unable to verify local backup archive", e) + false + } + } + + fun getBackupId(snapshotFileSystem: SnapshotFileSystem, messageBackupKey: MessageBackupKey): BackupId? { + return try { + val metadata = snapshotFileSystem.metadataInputStream()?.use { Metadata.ADAPTER.decode(it) } ?: return null + val encryptedBackupId = metadata.backupId ?: return null + decryptBackupId(encryptedBackupId, messageBackupKey) + } catch (e: Exception) { + Log.w(TAG, "Unable to decrypt local backup id", e) + null + } + } + + private fun decryptBackupId(encryptedBackupId: Metadata.EncryptedBackupId, messageBackupKey: MessageBackupKey): BackupId { + val metadataKey = messageBackupKey.deriveLocalBackupMetadataKey() val iv = encryptedBackupId.iv.toByteArray() val backupIdCipher = encryptedBackupId.encryptedId.toByteArray() @@ -227,7 +259,6 @@ object LocalArchiver { data object MainStream : RestoreFailure data object Cancelled : RestoreFailure data object BackupIdMissing : RestoreFailure - data object BackupIdMismatch : RestoreFailure data class VersionMismatch(val backupVersion: Int, val supportedVersion: Int) : RestoreFailure } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt index e531f33603..e045914754 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.backup.v2.stream import androidx.annotation.VisibleForTesting import com.google.common.io.CountingInputStream import org.signal.core.models.ServiceId.ACI +import org.signal.core.models.backup.BackupId import org.signal.core.models.backup.MessageBackupKey import org.signal.core.util.readFully import org.signal.core.util.readNBytesOrThrow @@ -66,9 +67,23 @@ class EncryptedBackupReader private constructor( forwardSecrecyToken: BackupForwardSecrecyToken, length: Long, dataStream: () -> InputStream + ): EncryptedBackupReader { + return createForSignalBackup(key, key.deriveBackupId(aci), forwardSecrecyToken, length, dataStream) + } + + /** + * Create a reader for a backup from the archive CDN, using a [BackupId] directly + * instead of deriving it from an ACI. + */ + fun createForSignalBackup( + key: MessageBackupKey, + backupId: BackupId, + forwardSecrecyToken: BackupForwardSecrecyToken, + length: Long, + dataStream: () -> InputStream ): EncryptedBackupReader { return EncryptedBackupReader( - keyMaterial = key.deriveBackupSecrets(aci, forwardSecrecyToken), + keyMaterial = key.deriveBackupSecrets(backupId, forwardSecrecyToken), length = length, dataStream = dataStream ) @@ -79,8 +94,16 @@ class EncryptedBackupReader private constructor( * The key difference is that we don't require forward secrecy data. */ fun createForLocalOrLinking(key: MessageBackupKey, aci: ACI, length: Long, dataStream: () -> InputStream): EncryptedBackupReader { + return createForLocalOrLinking(key, key.deriveBackupId(aci), length, dataStream) + } + + /** + * Create a reader for a local backup or for a transfer to a linked device, using a [BackupId] directly + * instead of deriving it from an ACI. + */ + fun createForLocalOrLinking(key: MessageBackupKey, backupId: BackupId, length: Long, dataStream: () -> InputStream): EncryptedBackupReader { return EncryptedBackupReader( - keyMaterial = key.deriveBackupSecrets(aci, forwardSecrecyToken = null), + keyMaterial = key.deriveBackupSecrets(backupId, forwardSecrecyToken = null), length = length, dataStream = dataStream ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupWriter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupWriter.kt index f926094ab0..eb9847fc71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupWriter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupWriter.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.backup.v2.stream import org.signal.core.models.ServiceId.ACI +import org.signal.core.models.backup.BackupId import org.signal.core.models.backup.MessageBackupKey import org.signal.core.util.Util import org.signal.core.util.stream.MacOutputStream @@ -13,7 +14,6 @@ import org.signal.core.util.writeVarInt32 import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo import org.thoughtcrime.securesms.backup.v2.proto.Frame -import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader.Companion.createForSignalBackup import java.io.IOException import java.io.OutputStream import javax.crypto.Cipher @@ -29,8 +29,7 @@ import javax.crypto.spec.SecretKeySpec * to the end of the [outputStream]. */ class EncryptedBackupWriter private constructor( - key: MessageBackupKey, - aci: ACI, + keyMaterial: MessageBackupKey.BackupKeyMaterial, forwardSecrecyToken: BackupForwardSecrecyToken?, forwardSecrecyMetadata: ByteArray?, private val outputStream: OutputStream, @@ -54,10 +53,24 @@ class EncryptedBackupWriter private constructor( forwardSecrecyMetadata: ByteArray, outputStream: OutputStream, append: (ByteArray) -> Unit + ): EncryptedBackupWriter { + return createForSignalBackup(key, key.deriveBackupId(aci), forwardSecrecyToken, forwardSecrecyMetadata, outputStream, append) + } + + /** + * Create a writer for a backup from the archive CDN, using a [BackupId] directly + * instead of deriving it from an ACI. + */ + fun createForSignalBackup( + key: MessageBackupKey, + backupId: BackupId, + forwardSecrecyToken: BackupForwardSecrecyToken, + forwardSecrecyMetadata: ByteArray, + outputStream: OutputStream, + append: (ByteArray) -> Unit ): EncryptedBackupWriter { return EncryptedBackupWriter( - key = key, - aci = aci, + keyMaterial = key.deriveBackupSecrets(backupId, forwardSecrecyToken), forwardSecrecyToken = forwardSecrecyToken, forwardSecrecyMetadata = forwardSecrecyMetadata, outputStream = outputStream, @@ -74,10 +87,22 @@ class EncryptedBackupWriter private constructor( aci: ACI, outputStream: OutputStream, append: (ByteArray) -> Unit + ): EncryptedBackupWriter { + return createForLocalOrLinking(key, key.deriveBackupId(aci), outputStream, append) + } + + /** + * Create a writer for a local backup or for a transfer to a linked device, using a [BackupId] directly + * instead of deriving it from an ACI. + */ + fun createForLocalOrLinking( + key: MessageBackupKey, + backupId: BackupId, + outputStream: OutputStream, + append: (ByteArray) -> Unit ): EncryptedBackupWriter { return EncryptedBackupWriter( - key = key, - aci = aci, + keyMaterial = key.deriveBackupSecrets(backupId, forwardSecrecyToken = null), forwardSecrecyToken = null, forwardSecrecyMetadata = null, outputStream = outputStream, @@ -99,8 +124,6 @@ class EncryptedBackupWriter private constructor( outputStream.flush() } - val keyMaterial = key.deriveBackupSecrets(aci, forwardSecrecyToken) - val iv: ByteArray = Util.getSecretBytes(16) outputStream.write(iv) outputStream.flush() diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index a209ec2d0c..75ce72ad78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -107,6 +107,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_NEW_LOCAL_BACKUPS_SELECTED_SNAPSHOT_TIMESTAMP = "backup.new_local_backups_selected_snapshot_timestamp" private const val KEY_NEW_LOCAL_BACKUPS_CREATION_PROGRESS = "backup.new_local_backups_creation_progress" + private const val KEY_LOCAL_RESTORE_ACCOUNT_ENTROPY_POOL = "backup.local_restore_account_entropy_pool" + private const val KEY_UPLOAD_BANNER_VISIBLE = "backup.upload_banner_visible" private val cachedCdnCredentialsExpiresIn: Duration = 12.hours @@ -501,6 +503,12 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { */ var newLocalBackupsSelectedSnapshotTimestamp: Long by longValue(KEY_NEW_LOCAL_BACKUPS_SELECTED_SNAPSHOT_TIMESTAMP, -1L) + /** + * Temporary storage for the AEP used to decrypt a local backup file. This is kept separate from + * the account AEP because the local backup may belong to a different account (e.g., after ACI change). + */ + var localRestoreAccountEntropyPool: String? by stringValue(KEY_LOCAL_RESTORE_ACCOUNT_ENTROPY_POOL, null as String?) + /** * When we are told by the server that we are out of storage space, we should show * UX treatment to make the user aware of this. diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt index 1257292f63..2a17b811f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt @@ -649,6 +649,11 @@ class RegistrationViewModel : ViewModel() { } } + /** Clears the recovery password from state, e.g. when a backup-key-based registration attempt fails and the stale password must not be retried. */ + fun clearRecoveryPassword() { + setRecoveryPassword(null) + } + private fun setRecoveryPassword(recoveryPassword: String?) { store.update { it.copy(recoveryPassword = recoveryPassword) @@ -930,7 +935,7 @@ class RegistrationViewModel : ViewModel() { Log.w(TAG, "Unable to start auth websocket", e) } - if (!remoteResult.reRegistration && SignalStore.registration.restoreDecisionState.isDecisionPending) { + if (!remoteResult.reRegistration && SignalStore.registration.restoreDecisionState.isDecisionPending && SignalStore.backup.localRestoreAccountEntropyPool == null) { Log.v(TAG, "Not re-registration, and still pending restore decision, likely an account with no data to restore, skipping post register restore") SignalStore.registration.restoreDecisionState = RestoreDecisionState.NewAccount } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyViewModel.kt index 152433b10f..61c374df6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyViewModel.kt @@ -19,15 +19,12 @@ import kotlinx.coroutines.launch import org.signal.core.models.AccountEntropyPool import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem +import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem -import org.thoughtcrime.securesms.backup.v2.local.proto.Metadata import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult import java.util.concurrent.atomic.AtomicInteger -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec class EnterBackupKeyViewModel : ViewModel() { @@ -93,20 +90,7 @@ class EnterBackupKeyViewModel : ViewModel() { val snapshot = archiveFileSystem.listSnapshots().firstOrNull { it.timestamp == selectedTimestamp } ?: return false val snapshotFs = SnapshotFileSystem(AppDependencies.application, snapshot.file) - val metadata = snapshotFs.metadataInputStream()?.use { Metadata.ADAPTER.decode(it) } ?: return false - val encryptedBackupId = metadata.backupId ?: return false - - val messageBackupKey = aep.deriveMessageBackupKey() - val metadataKey = messageBackupKey.deriveLocalBackupMetadataKey() - val iv = encryptedBackupId.iv.toByteArray() - val backupIdCipher = encryptedBackupId.encryptedId.toByteArray() - - val cipher = Cipher.getInstance("AES/CTR/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv)) - val decryptedBackupId = cipher.doFinal(backupIdCipher) - - val expectedBackupId = messageBackupKey.deriveBackupId(SignalStore.account.requireAci()) - return decryptedBackupId.contentEquals(expectedBackupId.value) + return LocalArchiver.canDecryptMainArchive(snapshotFs, aep.deriveMessageBackupKey()) } catch (e: Exception) { Log.w(TAG, "Failed to verify local backup key", e) return false @@ -117,6 +101,11 @@ class EnterBackupKeyViewModel : ViewModel() { store.update { it.copy(isRegistering = true) } } + /** Resets [EnterBackupKeyState.isRegistering] without triggering a registration error dialog. Use when navigation away from this screen is handling the error itself. */ + fun cancelRegistering() { + store.update { it.copy(isRegistering = false) } + } + fun handleRegistrationFailure(registerAccountResult: RegisterAccountResult) { store.update { if (it.isRegistering) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivity.kt index 28d9029d78..e2d3470e91 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivity.kt @@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.contactsupport.ContactSupportCallbacks import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialog import org.thoughtcrime.securesms.components.contactsupport.ContactSupportViewModel +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.restore.RestoreActivity import kotlin.math.max @@ -84,9 +85,11 @@ class RestoreLocalBackupActivity : BaseActivity() { LaunchedEffect(state.restorePhase) { when (state.restorePhase) { RestorePhase.COMPLETE -> { - startActivity(MainActivity.clearTop(this@RestoreLocalBackupActivity)) - if (finishActivity) { - finishAffinity() + if (!state.showLocalBackupsDisabledDialog) { + startActivity(MainActivity.clearTop(this@RestoreLocalBackupActivity)) + if (finishActivity) { + finishAffinity() + } } } @@ -105,6 +108,25 @@ class RestoreLocalBackupActivity : BaseActivity() { RestoreLocalBackupScreen( state = state, onContactSupportClick = contactSupportViewModel::showContactSupport, + onLocalBackupsDisabledDialogConfirm = { + viewModel.dismissLocalBackupsDisabledDialog() + startActivities( + arrayOf( + MainActivity.clearTop(this@RestoreLocalBackupActivity), + AppSettingsActivity.backups(this@RestoreLocalBackupActivity) + ) + ) + if (finishActivity) { + finishAffinity() + } + }, + onLocalBackupsDisabledDialogDeny = { + viewModel.dismissLocalBackupsDisabledDialog() + startActivity(MainActivity.clearTop(this@RestoreLocalBackupActivity)) + if (finishActivity) { + finishAffinity() + } + }, onFailureDialogConfirm = { if (finishActivity) { viewModel.resetRestoreState() @@ -126,6 +148,8 @@ class RestoreLocalBackupActivity : BaseActivity() { private fun RestoreLocalBackupScreen( state: RestoreLocalBackupScreenState, onFailureDialogConfirm: () -> Unit, + onLocalBackupsDisabledDialogConfirm: () -> Unit, + onLocalBackupsDisabledDialogDeny: () -> Unit, onContactSupportClick: () -> Unit, contactSupportState: ContactSupportViewModel.ContactSupportState, contactSupportCallbacks: ContactSupportCallbacks @@ -241,6 +265,17 @@ private fun RestoreLocalBackupScreen( ) } } + + if (state.showLocalBackupsDisabledDialog) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.RestoreLocalBackupActivity__turn_on_on_device_backups), + body = stringResource(R.string.RestoreLocalBackupActivity__to_continue_using_on_device_backups), + confirm = stringResource(R.string.RestoreLocalBackupActivity__turn_on_now), + dismiss = stringResource(R.string.RestoreLocalBackupActivity__not_now), + onConfirm = onLocalBackupsDisabledDialogConfirm, + onDeny = onLocalBackupsDisabledDialogDeny + ) + } } } @@ -251,6 +286,8 @@ private fun RestoreLocalBackupScreenPreview() { RestoreLocalBackupScreen( state = RestoreLocalBackupScreenState(), onFailureDialogConfirm = {}, + onLocalBackupsDisabledDialogConfirm = {}, + onLocalBackupsDisabledDialogDeny = {}, onContactSupportClick = {}, contactSupportState = ContactSupportViewModel.ContactSupportState(), contactSupportCallbacks = ContactSupportCallbacks.Empty diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt index 71b0f6429f..1672c2484d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.signal.core.models.AccountEntropyPool import org.signal.core.util.ByteSize import org.signal.core.util.Result import org.signal.core.util.bytes @@ -28,7 +29,6 @@ import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.jobs.LocalBackupJob import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob import org.thoughtcrime.securesms.keyvalue.Completed import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -110,23 +110,50 @@ class RestoreLocalBackupActivityViewModel : ViewModel() { return@launch } + val localAep = SignalStore.backup.localRestoreAccountEntropyPool + if (localAep == null) { + Log.w(TAG, "No local restore AEP set") + internalState.update { it.copy(restorePhase = RestorePhase.FAILED) } + return@launch + } + val localAepPool = AccountEntropyPool(localAep) + val messageBackupKey = localAepPool.deriveMessageBackupKey() + val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file) - val result = LocalArchiver.import(snapshotFileSystem, selfData) + val result = LocalArchiver.import(snapshotFileSystem, selfData, messageBackupKey) if (result is Result.Success) { Log.i(TAG, "Local backup import succeeded") val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles() RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo) + val actualBackupId = LocalArchiver.getBackupId(snapshotFileSystem, messageBackupKey) + val expectedBackupId = SignalStore.account.accountEntropyPool + .deriveMessageBackupKey() + .deriveBackupId(self.aci.get()) + + SignalStore.backup.localRestoreAccountEntropyPool = null SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed SignalStore.backup.backupSecretRestoreRequired = false SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = -1L - SignalStore.backup.newLocalBackupsEnabled = true - LocalBackupJob.enqueueArchive(false) + + val backupIdMatchesCurrentAccount = actualBackupId?.value?.contentEquals(expectedBackupId.value) == true + if (backupIdMatchesCurrentAccount) { + SignalStore.account.restoreAccountEntropyPool(localAepPool) + SignalStore.backup.newLocalBackupsEnabled = true + } else { + Log.w(TAG, "Local backup does not match current account, not re-enabling local backups") + } + StorageServiceRestore.restore() RegistrationUtil.maybeMarkRegistrationComplete() - internalState.update { it.copy(restorePhase = RestorePhase.COMPLETE) } + internalState.update { + it.copy( + restorePhase = RestorePhase.COMPLETE, + showLocalBackupsDisabledDialog = !backupIdMatchesCurrentAccount + ) + } } else { Log.w(TAG, "Local backup import failed") internalState.update { it.copy(restorePhase = RestorePhase.FAILED) } @@ -134,6 +161,10 @@ class RestoreLocalBackupActivityViewModel : ViewModel() { } } + fun dismissLocalBackupsDisabledDialog() { + internalState.update { it.copy(showLocalBackupsDisabledDialog = false) } + } + fun resetRestoreState() { SignalStore.registration.restoreDecisionState = RestoreDecisionState(decisionState = RestoreDecisionState.State.START) } @@ -143,7 +174,8 @@ data class RestoreLocalBackupScreenState( val restorePhase: RestorePhase = RestorePhase.RESTORING, val bytesRead: ByteSize = 0L.bytes, val totalBytes: ByteSize = 0L.bytes, - val progress: Float = 0f + val progress: Float = 0f, + val showLocalBackupsDisabledDialog: Boolean = false ) enum class RestorePhase { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupFragment.kt index 6c149755bc..6925c65fea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupFragment.kt @@ -34,6 +34,7 @@ import org.signal.core.ui.compose.ComposeFragment import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode import org.thoughtcrime.securesms.registration.ui.restore.EnterBackupKeyViewModel @@ -79,7 +80,18 @@ class RestoreLocalBackupFragment : ComposeFragment() { .filterNotNull() .collect { sharedViewModel.registerAccountErrorShown() - enterBackupKeyViewModel.handleRegistrationFailure(it) + if (it is RegisterAccountResult.IncorrectRecoveryPassword) { + SignalStore.account.resetAccountEntropyPool() + SignalStore.account.resetAciAndPniIdentityKeysAfterFailedRestore() + sharedViewModel.clearRecoveryPassword() + enterBackupKeyViewModel.cancelRegistering() + sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false, fromLocalV2 = true) + findNavController().safeNavigate( + RestoreLocalBackupFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION) + ) + } else { + enterBackupKeyViewModel.handleRegistrationFailure(it) + } } } } @@ -137,6 +149,8 @@ class RestoreLocalBackupFragment : ComposeFragment() { override fun submitBackupKey() { enterBackupKeyViewModel.registering() + SignalStore.backup.localRestoreAccountEntropyPool = enterBackupKeyViewModel.backupKey + val selectedTimestamp = restoreLocalBackupViewModel.state.value.selectedBackup?.timestamp ?: -1L SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = selectedTimestamp @@ -152,6 +166,8 @@ class RestoreLocalBackupFragment : ComposeFragment() { override fun onBackupKeyChanged(key: String) { enterBackupKeyViewModel.updateBackupKey(key) + val timestamp = restoreLocalBackupViewModel.state.value.selectedBackup?.timestamp ?: return + enterBackupKeyViewModel.verifyLocalBackupKey(timestamp) } override fun clearRegistrationError() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/local/PostRegistrationRestoreLocalBackupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/local/PostRegistrationRestoreLocalBackupFragment.kt index 32953fc2db..a57b9acb8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/local/PostRegistrationRestoreLocalBackupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/local/PostRegistrationRestoreLocalBackupFragment.kt @@ -100,8 +100,9 @@ class PostRegistrationRestoreLocalBackupFragment : ComposeFragment() { } override fun submitBackupKey() { - val aep = AccountEntropyPool.parseOrNull(enterBackupKeyViewModel.backupKey) ?: return - SignalStore.account.restoreAccountEntropyPool(aep) + AccountEntropyPool.parseOrNull(enterBackupKeyViewModel.backupKey) ?: return + + SignalStore.backup.localRestoreAccountEntropyPool = enterBackupKeyViewModel.backupKey val selectedTimestamp = restoreLocalBackupViewModel.state.value.selectedBackup?.timestamp ?: -1L SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = selectedTimestamp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a0278b7aa3..bfe1927383 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9174,6 +9174,18 @@ Contact support + + Turn on on-device backups? + + + To continue using on-device backups, you need to enable them for this account and record your new recovery key. You can do this at any time from Settings > Backups. + + + Turn on now + + + Not now + Select your backup folder diff --git a/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartRestoreActivity.kt b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartRestoreActivity.kt index 3b6d91fb8c..8f6c71a1c1 100644 --- a/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartRestoreActivity.kt +++ b/app/src/quickstart/java/org/thoughtcrime/securesms/QuickstartRestoreActivity.kt @@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.backup.v2.RestoreV2Event import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen import java.io.File @@ -152,10 +153,15 @@ class QuickstartRestoreActivity : BaseActivity() { // Import directly via BackupRepository, bypassing SnapshotFileSystem/LocalArchiver // to avoid DocumentFile.findFile name-matching issues + val backupKey = SignalStore.backup.messageBackupKey + val backupId = backupKey.deriveBackupId(selfData.aci) + val importResult = BackupRepository.importLocal( mainStreamFactory = { FileInputStream(mainFile) }, mainStreamLength = mainFile.length(), - selfData = selfData + selfData = selfData, + backupId = backupId, + messageBackupKey = backupKey ) Log.i(TAG, "Import result: $importResult") diff --git a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiverTest.kt b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiverTest.kt new file mode 100644 index 0000000000..80e1040ef1 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiverTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.local + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import okio.ByteString.Companion.toByteString +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.models.backup.BackupId +import org.signal.core.models.backup.MessageBackupKey +import org.signal.core.util.Util +import org.thoughtcrime.securesms.backup.v2.local.proto.Metadata +import org.thoughtcrime.securesms.backup.v2.proto.AccountData +import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo +import org.thoughtcrime.securesms.backup.v2.proto.Frame +import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter +import java.io.ByteArrayOutputStream +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class LocalArchiverTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun `canDecryptMainArchive returns true for valid key`() { + val messageBackupKey = MessageBackupKey(Util.getSecretBytes(32)) + val snapshot = createSnapshot() + + writeSnapshotFiles(snapshot, messageBackupKey) + + assertThat(LocalArchiver.canDecryptMainArchive(snapshot, messageBackupKey)).isTrue() + } + + @Test + fun `canDecryptMainArchive returns false for wrong key`() { + val validKey = MessageBackupKey(Util.getSecretBytes(32)) + val wrongKey = MessageBackupKey(Util.getSecretBytes(32)) + val snapshot = createSnapshot() + + writeSnapshotFiles(snapshot, validKey) + + assertThat(LocalArchiver.canDecryptMainArchive(snapshot, wrongKey)).isFalse() + } + + @Test + fun `canDecryptMainArchive returns false when metadata is missing`() { + val messageBackupKey = MessageBackupKey(Util.getSecretBytes(32)) + val snapshot = createSnapshot() + + writeMainArchive(snapshot, messageBackupKey, BackupId(Util.getSecretBytes(16))) + + assertThat(LocalArchiver.canDecryptMainArchive(snapshot, messageBackupKey)).isFalse() + } + + @Test + fun `canDecryptMainArchive returns false when main archive is corrupted`() { + val messageBackupKey = MessageBackupKey(Util.getSecretBytes(32)) + val snapshot = createSnapshot() + + writeSnapshotFiles(snapshot, messageBackupKey, corruptMainArchive = true) + + assertThat(LocalArchiver.canDecryptMainArchive(snapshot, messageBackupKey)).isFalse() + } + + @Test + fun `getBackupId returns correct id for valid key`() { + val messageBackupKey = MessageBackupKey(Util.getSecretBytes(32)) + val snapshot = createSnapshot() + val backupId = BackupId(Util.getSecretBytes(16)) + + snapshot.metadataOutputStream()!!.use { it.write(createMetadata(messageBackupKey, backupId).encode()) } + + val result = LocalArchiver.getBackupId(snapshot, messageBackupKey) + assertThat(result?.value?.contentEquals(backupId.value) == true).isTrue() + } + + @Test + fun `getBackupId returns null when metadata is missing`() { + val messageBackupKey = MessageBackupKey(Util.getSecretBytes(32)) + val snapshot = createSnapshot() + + assertThat(LocalArchiver.getBackupId(snapshot, messageBackupKey) == null).isTrue() + } + + @Test + fun `getBackupId returns wrong id for wrong key`() { + val validKey = MessageBackupKey(Util.getSecretBytes(32)) + val wrongKey = MessageBackupKey(Util.getSecretBytes(32)) + val snapshot = createSnapshot() + val backupId = BackupId(Util.getSecretBytes(16)) + + snapshot.metadataOutputStream()!!.use { it.write(createMetadata(validKey, backupId).encode()) } + + val result = LocalArchiver.getBackupId(snapshot, wrongKey) + assertThat(result?.value?.contentEquals(backupId.value) == true).isFalse() + } + + private fun createSnapshot(): SnapshotFileSystem { + val archiveRoot = temporaryFolder.newFolder() + return ArchiveFileSystem.fromFile(context, archiveRoot).createSnapshot()!! + } + + private fun writeSnapshotFiles( + snapshot: SnapshotFileSystem, + messageBackupKey: MessageBackupKey, + corruptMainArchive: Boolean = false + ) { + val backupId = BackupId(Util.getSecretBytes(16)) + + snapshot.metadataOutputStream()!!.use { it.write(createMetadata(messageBackupKey, backupId).encode()) } + writeMainArchive(snapshot, messageBackupKey, backupId, corruptMainArchive) + } + + private fun writeMainArchive( + snapshot: SnapshotFileSystem, + messageBackupKey: MessageBackupKey, + backupId: BackupId, + corruptMainArchive: Boolean = false + ) { + val output = ByteArrayOutputStream() + + EncryptedBackupWriter.createForLocalOrLinking( + key = messageBackupKey, + backupId = backupId, + outputStream = output, + append = { output.write(it) } + ).use { writer -> + writer.write(BackupInfo(version = 1, backupTimeMs = 1000L)) + writer.write(Frame(account = AccountData(username = "username"))) + } + + val bytes = output.toByteArray() + if (corruptMainArchive) { + bytes[bytes.lastIndex] = bytes.last().xor(0x01) + } + + snapshot.mainOutputStream()!!.use { it.write(bytes) } + } + + private fun createMetadata(messageBackupKey: MessageBackupKey, backupId: BackupId): Metadata { + val metadataKey = messageBackupKey.deriveLocalBackupMetadataKey() + val iv = Util.getSecretBytes(12) + val cipherText = Cipher.getInstance("AES/CTR/NoPadding").run { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv)) + doFinal(backupId.value) + } + + return Metadata( + version = 1, + backupId = Metadata.EncryptedBackupId( + iv = iv.toByteString(), + encryptedId = cipherText.toByteString() + ) + ) + } + + private fun Byte.xor(mask: Int): Byte { + return (toInt() xor mask).toByte() + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReaderWriterTest.kt b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReaderWriterTest.kt index 8f9405546d..0e918fcb62 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReaderWriterTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReaderWriterTest.kt @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.backup.v2.stream import org.junit.Assert.assertEquals import org.junit.Test import org.signal.core.models.ServiceId.ACI +import org.signal.core.models.backup.BackupId import org.signal.core.models.backup.MessageBackupKey import org.signal.core.util.Base64 import org.signal.core.util.Hex @@ -100,6 +101,36 @@ class EncryptedBackupReaderWriterTest { assertEquals(count, uniqueOutputs.size) } + @Test + fun `can read back all frames using BackupId directly - local`() { + val key = MessageBackupKey(Util.getSecretBytes(32)) + val backupId = BackupId(Util.getSecretBytes(16)) + + val outputStream = ByteArrayOutputStream() + val frameCount = 10_000 + EncryptedBackupWriter.createForLocalOrLinking(key, backupId, outputStream, append = { outputStream.write(it) }).use { writer -> + writer.write(BackupInfo(version = 1, backupTimeMs = 1000L)) + + for (i in 0 until frameCount) { + writer.write(Frame(account = AccountData(username = "username-$i"))) + } + } + + val ciphertext: ByteArray = outputStream.toByteArray() + + val frames: List = EncryptedBackupReader.createForLocalOrLinking(key, backupId, ciphertext.size.toLong()) { ciphertext.inputStream() }.use { reader -> + assertEquals(reader.backupInfo?.version, 1L) + assertEquals(reader.backupInfo?.backupTimeMs, 1000L) + reader.asSequence().toList() + } + + assertEquals(frameCount, frames.size) + + for (i in 0 until frameCount) { + assertEquals("username-$i", frames[i].account?.username) + } + } + @Test fun `can read back all of the frames we write - forward secrecy`() { val key = MessageBackupKey(Util.getSecretBytes(32)) @@ -140,4 +171,73 @@ class EncryptedBackupReaderWriterTest { assertEquals("username-$i", frames[i].account?.username) } } + + @Test + fun `can read back all frames using BackupId directly - forward secrecy`() { + val key = MessageBackupKey(Util.getSecretBytes(32)) + val backupId = BackupId(Util.getSecretBytes(16)) + val forwardSecrecyToken = BackupForwardSecrecyToken(Util.getSecretBytes(32)) + + val outputStream = ByteArrayOutputStream() + val frameCount = 10_000 + EncryptedBackupWriter.createForSignalBackup( + key = key, + backupId = backupId, + forwardSecrecyToken = forwardSecrecyToken, + forwardSecrecyMetadata = Util.getSecretBytes(64), + outputStream = outputStream, + append = { outputStream.write(it) } + ).use { writer -> + writer.write(BackupInfo(version = 1, backupTimeMs = 1000L)) + + for (i in 0 until frameCount) { + writer.write(Frame(account = AccountData(username = "username-$i"))) + } + } + + val ciphertext: ByteArray = outputStream.toByteArray() + + val frames: List = EncryptedBackupReader.createForSignalBackup(key, backupId, forwardSecrecyToken, ciphertext.size.toLong()) { ciphertext.inputStream() }.use { reader -> + assertEquals(reader.backupInfo?.version, 1L) + assertEquals(reader.backupInfo?.backupTimeMs, 1000L) + reader.asSequence().toList() + } + + assertEquals(frameCount, frames.size) + + for (i in 0 until frameCount) { + assertEquals("username-$i", frames[i].account?.username) + } + } + + @Test + fun `can write and read using BackupId for both - local`() { + val key = MessageBackupKey(Util.getSecretBytes(32)) + val backupId = BackupId(Util.getSecretBytes(16)) + + val outputStream = ByteArrayOutputStream() + + val frameCount = 10_000 + EncryptedBackupWriter.createForLocalOrLinking(key, backupId, outputStream, append = { outputStream.write(it) }).use { writer -> + writer.write(BackupInfo(version = 1, backupTimeMs = 1000L)) + + for (i in 0 until frameCount) { + writer.write(Frame(account = AccountData(username = "username-$i"))) + } + } + + val ciphertext: ByteArray = outputStream.toByteArray() + + val frames: List = EncryptedBackupReader.createForLocalOrLinking(key, backupId, ciphertext.size.toLong()) { ciphertext.inputStream() }.use { reader -> + assertEquals(reader.backupInfo?.version, 1L) + assertEquals(reader.backupInfo?.backupTimeMs, 1000L) + reader.asSequence().toList() + } + + assertEquals(frameCount, frames.size) + + for (i in 0 until frameCount) { + assertEquals("username-$i", frames[i].account?.username) + } + } } diff --git a/core/models-jvm/src/main/java/org/signal/core/models/backup/MessageBackupKey.kt b/core/models-jvm/src/main/java/org/signal/core/models/backup/MessageBackupKey.kt index d4c1c0449a..228561cb51 100644 --- a/core/models-jvm/src/main/java/org/signal/core/models/backup/MessageBackupKey.kt +++ b/core/models-jvm/src/main/java/org/signal/core/models/backup/MessageBackupKey.kt @@ -34,7 +34,14 @@ class MessageBackupKey(override val value: ByteArray) : BackupKey { * @param forwardSecrecyToken Should be present for any backup located on the archive CDN. Absent for other uses (i.e. link+sync). */ fun deriveBackupSecrets(aci: ServiceId.ACI, forwardSecrecyToken: BackupForwardSecrecyToken?): BackupKeyMaterial { - val backupId = deriveBackupId(aci) + return deriveBackupSecrets(deriveBackupId(aci), forwardSecrecyToken) + } + + /** + * Derives the cryptographic material used to encrypt a backup, using a [BackupId] directly + * instead of deriving it from an ACI. + */ + fun deriveBackupSecrets(backupId: BackupId, forwardSecrecyToken: BackupForwardSecrecyToken?): BackupKeyMaterial { val libsignalBackupKey = LibSignalBackupKey(value) val libsignalMessageMessageBackupKey = MessageBackupKey(libsignalBackupKey, backupId.value, forwardSecrecyToken)