Add internal UI for importing backup with different credentials.

Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
Jordan Rose 2024-12-19 11:15:27 -08:00 committed by Greyson Parrelli
parent 0cfda852cf
commit fa32f399b2
7 changed files with 185 additions and 51 deletions

View file

@ -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()

View file

@ -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())

View file

@ -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()
}
}

View file

@ -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
)
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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 {