mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-17 07:23:21 +01:00
Fix local backup restore AEP handling and conditional re-enable.
This commit is contained in:
committed by
Cody Henthorne
parent
c7a6c7ad9e
commit
78d3db319c
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Unit>,
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9174,6 +9174,18 @@
|
||||
<!-- RestoreLocalBackupActivity: Label for the button that opens the contact support form -->
|
||||
<string name="RestoreLocalBackupActivity__contact_support">Contact support</string>
|
||||
|
||||
<!-- RestoreLocalBackupActivity: Title for the dialog shown when the restored backup belongs to a different account -->
|
||||
<string name="RestoreLocalBackupActivity__turn_on_on_device_backups">Turn on on-device backups?</string>
|
||||
|
||||
<!-- RestoreLocalBackupActivity: Body for the dialog shown when the restored backup belongs to a different account, prompting the user to re-enable on-device backups -->
|
||||
<string name="RestoreLocalBackupActivity__to_continue_using_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.</string>
|
||||
|
||||
<!-- RestoreLocalBackupActivity: Confirm button label for the dialog prompting the user to re-enable on-device backups -->
|
||||
<string name="RestoreLocalBackupActivity__turn_on_now">Turn on now</string>
|
||||
|
||||
<!-- RestoreLocalBackupActivity: Dismiss button label for the dialog prompting the user to re-enable on-device backups -->
|
||||
<string name="RestoreLocalBackupActivity__not_now">Not now</string>
|
||||
|
||||
<!-- SelectInstructionsSheet: Title for the select backup folder instruction sheet -->
|
||||
<string name="SelectInstructionsSheet__select_your_backup_folder">Select your backup folder</string>
|
||||
<!-- SelectInstructionsSheet: Instruction to tap the select this folder button -->
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Frame> = 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<Frame> = 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<Frame> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user