diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt index 69f35ee7d8..7ed8302521 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -90,6 +90,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { private lateinit var exportFileLauncher: ActivityResultLauncher private lateinit var importFileLauncher: ActivityResultLauncher private lateinit var validateFileLauncher: ActivityResultLauncher + private lateinit var savePlaintextcopyLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -124,6 +125,15 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { } ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show() } } + + savePlaintextcopyLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.data?.let { uri -> + viewModel.fetchRemoteBackupAndWritePlaintext(requireContext().contentResolver.openOutputStream(uri)) + Toast.makeText(requireContext(), "Check logs for progress.", Toast.LENGTH_SHORT).show() + } ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show() + } + } } @Composable @@ -188,7 +198,17 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { .show() }, onBackupTierSelected = { tier -> viewModel.onBackupTierSelected(tier) }, - onHaltAllJobs = { viewModel.haltAllJobs() } + onHaltAllJobs = { viewModel.haltAllJobs() }, + onSavePlaintextCopy = { + val intent = Intent().apply { + action = Intent.ACTION_CREATE_DOCUMENT + type = "application/octet-stream" + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, "backup-plaintext-${System.currentTimeMillis()}.binproto") + } + + savePlaintextcopyLauncher.launch(intent) + } ) }, mediaContent = { snackbarHostState -> @@ -283,7 +303,8 @@ fun Screen( onTriggerBackupJobClicked: () -> Unit = {}, onWipeDataAndRestoreClicked: () -> Unit = {}, onBackupTierSelected: (MessageBackupTier?) -> Unit = {}, - onHaltAllJobs: () -> Unit = {} + onHaltAllJobs: () -> Unit = {}, + onSavePlaintextCopy: () -> Unit = {} ) { val scrollState = rememberScrollState() val options = remember { @@ -337,6 +358,12 @@ fun Screen( Text("Halt all backup jobs") } + Buttons.LargeTonal( + onClick = onSavePlaintextCopy + ) { + Text("Save plaintext copy of remote backup") + } + Dividers.Default() Row( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt index df69c7ea44..30a9ba3b8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt @@ -19,6 +19,10 @@ import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.copyTo +import org.signal.core.util.logging.Log +import org.signal.core.util.readNBytesOrThrow +import org.signal.core.util.stream.LimitedInputStream import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.attachments.DatabaseAttachment @@ -30,6 +34,7 @@ import org.thoughtcrime.securesms.backup.v2.local.ArchiveResult import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver.FailureCause import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem +import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader.Companion.MAC_SIZE import org.thoughtcrime.securesms.database.MessageType import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -45,16 +50,28 @@ import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob 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.NetworkResult import org.whispersystems.signalservice.api.backup.MediaName import java.io.ByteArrayInputStream +import java.io.IOException import java.io.InputStream +import java.io.OutputStream import java.util.UUID +import java.util.zip.GZIPInputStream +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec import kotlin.time.Duration.Companion.seconds class InternalBackupPlaygroundViewModel : ViewModel() { + companion object { + private val TAG = Log.tag(InternalBackupPlaygroundViewModel::class) + } + var backupData: ByteArray? = null val disposables = CompositeDisposable() @@ -520,4 +537,39 @@ class InternalBackupPlaygroundViewModel : ViewModel() { AppDependencies.jobManager.cancelAllInQueue("ArchiveAttachmentJobs_1") AppDependencies.jobManager.cancelAllInQueue("ArchiveThumbnailUploadJob") } + + fun fetchRemoteBackupAndWritePlaintext(outputStream: OutputStream?) { + check(outputStream != null) + + SignalExecutors.BOUNDED_IO.execute { + Log.d(TAG, "Downloading file...") + val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application) + if (!BackupRepository.downloadBackupFile(tempBackupFile)) { + Log.e(TAG, "Failed to download backup file") + throw IOException() + } + + val encryptedStream = tempBackupFile.inputStream() + val iv = encryptedStream.readNBytesOrThrow(16) + val backupKey = SignalStore.svr.orCreateMasterKey.deriveBackupKey() + val keyMaterial = backupKey.deriveBackupSecrets(Recipient.self().aci.get()) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { + init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(iv)) + } + + val plaintextStream = GZIPInputStream( + CipherInputStream( + LimitedInputStream( + wrapped = encryptedStream, + maxBytes = tempBackupFile.length() - MAC_SIZE + ), + cipher + ) + ) + + Log.d(TAG, "Copying...") + plaintextStream.copyTo(outputStream) + Log.d(TAG, "Done!") + } + } }