Add internal UI for importing backup with different credentials.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
parent
0cfda852cf
commit
fa32f399b2
7 changed files with 185 additions and 51 deletions
|
@ -641,14 +641,21 @@ object BackupRepository {
|
|||
}
|
||||
|
||||
return frameReader.use { reader ->
|
||||
import(backupKey, reader, selfData, cancellationSignal = { false })
|
||||
import(reader, selfData, cancellationSignal = { false })
|
||||
}
|
||||
}
|
||||
|
||||
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false, cancellationSignal: () -> Boolean = { false }): ImportResult {
|
||||
val backupKey = SignalStore.backup.messageBackupKey
|
||||
|
||||
val frameReader = if (plaintext) {
|
||||
/**
|
||||
* @param backupKey The key used to encrypt the backup. If `null`, we assume that the file is plaintext.
|
||||
*/
|
||||
fun import(
|
||||
length: Long,
|
||||
inputStreamFactory: () -> InputStream,
|
||||
selfData: SelfData,
|
||||
backupKey: MessageBackupKey?,
|
||||
cancellationSignal: () -> Boolean = { false }
|
||||
): ImportResult {
|
||||
val frameReader = if (backupKey == null) {
|
||||
PlainTextBackupReader(inputStreamFactory(), length)
|
||||
} else {
|
||||
EncryptedBackupReader(
|
||||
|
@ -660,12 +667,11 @@ object BackupRepository {
|
|||
}
|
||||
|
||||
return frameReader.use { reader ->
|
||||
import(backupKey, reader, selfData, cancellationSignal)
|
||||
import(reader, selfData, cancellationSignal)
|
||||
}
|
||||
}
|
||||
|
||||
private fun import(
|
||||
messageBackupKey: MessageBackupKey,
|
||||
frameReader: BackupImportReader,
|
||||
selfData: SelfData,
|
||||
cancellationSignal: () -> Boolean
|
||||
|
@ -747,7 +753,7 @@ object BackupRepository {
|
|||
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
|
||||
SignalDatabase.recipients.setProfileSharing(selfId, true)
|
||||
|
||||
val importState = ImportState(messageBackupKey, mediaRootBackupKey)
|
||||
val importState = ImportState(mediaRootBackupKey)
|
||||
val chatItemInserter: ChatItemArchiveImporter = ChatItemArchiveProcessor.beginImport(importState)
|
||||
|
||||
Log.d(TAG, "[import] Beginning to read frames.")
|
||||
|
@ -1500,7 +1506,7 @@ class ExportState(val backupTime: Long, val mediaBackupEnabled: Boolean) {
|
|||
val localToRemoteCustomChatColors: MutableMap<Long, Int> = hashMapOf()
|
||||
}
|
||||
|
||||
class ImportState(val messageBackupKey: MessageBackupKey, val mediaRootBackupKey: MediaRootBackupKey) {
|
||||
class ImportState(val mediaRootBackupKey: MediaRootBackupKey) {
|
||||
val remoteToLocalRecipientId: MutableMap<Long, RecipientId> = hashMapOf()
|
||||
val chatIdToLocalThreadId: MutableMap<Long, Long> = hashMapOf()
|
||||
val chatIdToLocalRecipientId: MutableMap<Long, RecipientId> = hashMapOf()
|
||||
|
|
|
@ -31,8 +31,7 @@ import javax.crypto.spec.SecretKeySpec
|
|||
* that decrypted data is gunzipped, then that data is read as frames.
|
||||
*/
|
||||
class EncryptedBackupReader(
|
||||
key: MessageBackupKey,
|
||||
aci: ACI,
|
||||
keyMaterial: MessageBackupKey.BackupKeyMaterial,
|
||||
val length: Long,
|
||||
dataStream: () -> InputStream
|
||||
) : BackupImportReader {
|
||||
|
@ -42,9 +41,11 @@ class EncryptedBackupReader(
|
|||
val stream: InputStream
|
||||
val countingStream: CountingInputStream
|
||||
|
||||
init {
|
||||
val keyMaterial = key.deriveBackupSecrets(aci)
|
||||
constructor(key: MessageBackupKey, aci: ACI, length: Long, dataStream: () -> InputStream) :
|
||||
this(key.deriveBackupSecrets(aci), length, dataStream) {
|
||||
}
|
||||
|
||||
init {
|
||||
dataStream().use { validateMac(keyMaterial.macKey, length, it) }
|
||||
|
||||
countingStream = CountingInputStream(dataStream())
|
||||
|
|
|
@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
|
@ -57,9 +58,11 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
@ -68,10 +71,12 @@ import org.signal.core.ui.Previews
|
|||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.Snackbars
|
||||
import org.signal.core.ui.TextFields.TextField
|
||||
import org.signal.core.util.getLength
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.DialogState
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
|
@ -190,13 +195,24 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
|||
.show()
|
||||
},
|
||||
onImportEncryptedBackupFromDiskClicked = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
viewModel.onImportSelected()
|
||||
},
|
||||
onImportEncryptedBackupFromDiskDismissed = {
|
||||
viewModel.onDialogDismissed()
|
||||
},
|
||||
onImportEncryptedBackupFromDiskConfirmed = { aci, aep ->
|
||||
viewModel.onDialogDismissed()
|
||||
val valid = viewModel.onImportConfirmed(aci, aep)
|
||||
if (valid) {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
importEncryptedBackupFromDiskLauncher.launch(intent)
|
||||
} else {
|
||||
Toast.makeText(context, "Invalid credentials!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
importEncryptedBackupFromDiskLauncher.launch(intent)
|
||||
},
|
||||
onImportNewStyleLocalBackupClicked = {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
|
@ -297,7 +313,9 @@ fun Screen(
|
|||
onValidateBackupClicked: () -> Unit = {},
|
||||
onSaveEncryptedBackupToDiskClicked: () -> Unit = {},
|
||||
onSavePlaintextBackupToDiskClicked: () -> Unit = {},
|
||||
onImportEncryptedBackupFromDiskClicked: () -> Unit = {}
|
||||
onImportEncryptedBackupFromDiskClicked: () -> Unit = {},
|
||||
onImportEncryptedBackupFromDiskDismissed: () -> Unit = {},
|
||||
onImportEncryptedBackupFromDiskConfirmed: (aesKey: String, macKey: String) -> Unit = { _, _ -> }
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val options = remember {
|
||||
|
@ -308,6 +326,16 @@ fun Screen(
|
|||
)
|
||||
}
|
||||
|
||||
when (state.dialog) {
|
||||
DialogState.None -> Unit
|
||||
DialogState.ImportCredentials -> {
|
||||
ImportCredentialsDialog(
|
||||
onSubmit = onImportEncryptedBackupFromDiskConfirmed,
|
||||
onDismissed = onImportEncryptedBackupFromDiskDismissed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Surface {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
|
@ -451,11 +479,53 @@ fun Screen(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun StateLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center
|
||||
private fun ImportCredentialsDialog(onSubmit: (String, String) -> Unit = { _, _ -> }, onDismissed: () -> Unit = {}) {
|
||||
val dialogScrollState = rememberScrollState()
|
||||
var aesKey by remember { mutableStateOf("") }
|
||||
var macKey by remember { mutableStateOf("") }
|
||||
val inputOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Next
|
||||
)
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = { Text(text = "Are you sure?") },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(dialogScrollState)) {
|
||||
Row(modifier = Modifier.padding(vertical = 10.dp)) {
|
||||
Text(text = "This will delete all of your chats! It's also not entirely realistic, because normally restores only happen during registration. Only do this on a test device!")
|
||||
}
|
||||
Row(modifier = Modifier.padding(vertical = 10.dp)) {
|
||||
TextField(
|
||||
value = aesKey,
|
||||
keyboardOptions = inputOptions,
|
||||
label = { Text("ACI") },
|
||||
supportingText = { Text("(leave blank for the current user)") },
|
||||
onValueChange = { aesKey = it }
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.padding(vertical = 10.dp)) {
|
||||
TextField(
|
||||
value = macKey,
|
||||
keyboardOptions = inputOptions.copy(imeAction = ImeAction.Done),
|
||||
label = { Text("\"Backup Key\" (AEP)") },
|
||||
supportingText = { Text("(leave blank for the current user)") },
|
||||
onValueChange = { macKey = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onSubmit(aesKey, macKey)
|
||||
}) {
|
||||
Text(text = "Wipe and restore")
|
||||
}
|
||||
},
|
||||
modifier = Modifier,
|
||||
properties = DialogProperties()
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -498,16 +568,13 @@ fun MediaList(
|
|||
val attachment = state.attachments[index]
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (selectionState.selecting) {
|
||||
selectionState = selectionState.copy(selected = if (selectionState.selected.contains(attachment.id)) selectionState.selected - attachment.id else selectionState.selected + attachment.id)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
selectionState = if (selectionState.selecting) MediaMultiSelectState() else MediaMultiSelectState(selecting = true, selected = setOf(attachment.id))
|
||||
.combinedClickable(onClick = {
|
||||
if (selectionState.selecting) {
|
||||
selectionState = selectionState.copy(selected = if (selectionState.selected.contains(attachment.id)) selectionState.selected - attachment.id else selectionState.selected + attachment.id)
|
||||
}
|
||||
)
|
||||
}, onLongClick = {
|
||||
selectionState = if (selectionState.selecting) MediaMultiSelectState() else MediaMultiSelectState(selecting = true, selected = setOf(attachment.id))
|
||||
})
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
if (selectionState.selecting) {
|
||||
|
@ -650,3 +717,11 @@ fun PreviewScreenExportInProgress() {
|
|||
Screen(state = ScreenState(statusMessage = "Some random status message."))
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
fun PreviewImportCredentialDialog() {
|
||||
Previews.Preview {
|
||||
ImportCredentialsDialog()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
|||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.copyTo
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.roundedString
|
||||
|
@ -55,8 +56,10 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
|||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
@ -91,6 +94,11 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
|||
private val _mediaState: MutableState<MediaState> = mutableStateOf(MediaState())
|
||||
val mediaState: State<MediaState> = _mediaState
|
||||
|
||||
enum class DialogState {
|
||||
None,
|
||||
ImportCredentials
|
||||
}
|
||||
|
||||
fun exportEncrypted(openStream: () -> OutputStream, appendStream: () -> OutputStream) {
|
||||
_state.value = _state.value.copy(statusMessage = "Exporting encrypted backup to disk...")
|
||||
disposables += Single
|
||||
|
@ -164,12 +172,15 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
fun importEncryptedBackup(length: Long, inputStreamFactory: () -> InputStream) {
|
||||
_state.value = _state.value.copy(statusMessage = "Importing encrypted backup...")
|
||||
val customCredentials: ImportCredentials? = _state.value.customBackupCredentials
|
||||
_state.value = _state.value.copy(statusMessage = "Importing encrypted backup...", customBackupCredentials = null)
|
||||
|
||||
val self = Recipient.self()
|
||||
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
|
||||
val aci = customCredentials?.aci ?: self.aci.get()
|
||||
val selfData = BackupRepository.SelfData(aci, self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
|
||||
val backupKey = customCredentials?.aep?.deriveMessageBackupKey() ?: SignalStore.backup.messageBackupKey
|
||||
|
||||
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = false) }
|
||||
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, backupKey) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy {
|
||||
|
@ -286,6 +297,35 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
|||
_state.value = _state.value.copy(backupTier = backupTier)
|
||||
}
|
||||
|
||||
fun onImportSelected() {
|
||||
_state.value = _state.value.copy(dialog = DialogState.ImportCredentials)
|
||||
}
|
||||
|
||||
/** True if data is valid, else false */
|
||||
fun onImportConfirmed(aci: String, aep: String): Boolean {
|
||||
val parsedAci: ACI? = ACI.parseOrNull(aci)
|
||||
if (aci.isNotBlank() && parsedAci == null) {
|
||||
_state.value = _state.value.copy(statusMessage = "Invalid ACI! Cannot import.")
|
||||
return false
|
||||
}
|
||||
|
||||
val parsedAep = AccountEntropyPool.parseOrNull(aep)
|
||||
if (aep.isNotBlank() && parsedAep == null) {
|
||||
_state.value = _state.value.copy(statusMessage = "Invalid AEP! Cannot import.")
|
||||
return false
|
||||
}
|
||||
|
||||
if (parsedAci != null && parsedAep != null) {
|
||||
_state.value = state.value.copy(customBackupCredentials = ImportCredentials(aep = parsedAep, aci = parsedAci))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun onDialogDismissed() {
|
||||
_state.value = _state.value.copy(dialog = DialogState.None)
|
||||
}
|
||||
|
||||
private fun restoreFromRemote() {
|
||||
_state.value = _state.value.copy(statusMessage = "Importing from remote...")
|
||||
|
||||
|
@ -484,19 +524,14 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
|||
data class ScreenState(
|
||||
val canReadWriteBackupDirectory: Boolean = false,
|
||||
val backupTier: MessageBackupTier? = null,
|
||||
val statusMessage: String? = null
|
||||
val statusMessage: String? = null,
|
||||
val customBackupCredentials: ImportCredentials? = null,
|
||||
val dialog: DialogState = DialogState.None
|
||||
)
|
||||
|
||||
enum class BackupState(val inProgress: Boolean = false) {
|
||||
NONE,
|
||||
EXPORT_IN_PROGRESS(true),
|
||||
EXPORT_DONE,
|
||||
IMPORT_IN_PROGRESS(true)
|
||||
}
|
||||
|
||||
sealed class RemoteBackupState {
|
||||
object Unknown : RemoteBackupState()
|
||||
object NotFound : RemoteBackupState()
|
||||
data object Unknown : RemoteBackupState()
|
||||
data object NotFound : RemoteBackupState()
|
||||
data class Available(val response: BackupMetadata) : RemoteBackupState()
|
||||
}
|
||||
|
||||
|
@ -565,4 +600,9 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
|||
fun <T> MutableState<T>.set(update: T.() -> T) {
|
||||
this.value = this.value.update()
|
||||
}
|
||||
|
||||
data class ImportCredentials(
|
||||
val aep: AccountEntropyPool,
|
||||
val aci: ACI
|
||||
)
|
||||
}
|
||||
|
|
|
@ -103,7 +103,7 @@ class BackupRestoreJob private constructor(parameters: Parameters) : BaseJob(par
|
|||
|
||||
val self = Recipient.self()
|
||||
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
|
||||
BackupRepository.import(length = tempBackupFile.length(), inputStreamFactory = tempBackupFile::inputStream, selfData = selfData, plaintext = false, cancellationSignal = { isCanceled })
|
||||
BackupRepository.import(length = tempBackupFile.length(), inputStreamFactory = tempBackupFile::inputStream, selfData = selfData, backupKey = SignalStore.backup.messageBackupKey, cancellationSignal = { isCanceled })
|
||||
|
||||
SignalStore.backup.restoreState = RestoreState.RESTORING_MEDIA
|
||||
}
|
||||
|
|
|
@ -15,9 +15,21 @@ import org.signal.libsignal.messagebackup.AccountEntropyPool as LibSignalAccount
|
|||
class AccountEntropyPool(val value: String) {
|
||||
|
||||
companion object {
|
||||
private val INVALID_CHARACTERS = Regex("[^0-9a-zA-Z]")
|
||||
private const val LENGTH = 64
|
||||
|
||||
fun generate(): AccountEntropyPool {
|
||||
return AccountEntropyPool(LibSignalAccountEntropyPool.generate())
|
||||
}
|
||||
|
||||
fun parseOrNull(input: String): AccountEntropyPool? {
|
||||
val stripped = input.replace(INVALID_CHARACTERS, "")
|
||||
if (stripped.length != LENGTH) {
|
||||
return null
|
||||
}
|
||||
|
||||
return AccountEntropyPool(stripped)
|
||||
}
|
||||
}
|
||||
|
||||
fun deriveMasterKey(): MasterKey {
|
||||
|
|
|
@ -12,7 +12,7 @@ import org.signal.libsignal.messagebackup.MessageBackupKey as LibSignalMessageBa
|
|||
|
||||
/**
|
||||
* Safe typing around a backup key, which is a 32-byte array.
|
||||
* This key is derived from the master key.
|
||||
* This key is derived from the AEP.
|
||||
*/
|
||||
class MessageBackupKey(override val value: ByteArray) : BackupKey {
|
||||
init {
|
||||
|
|
Loading…
Add table
Reference in a new issue