From 218964cbda66c7decd98637aec02a80b581543fc Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Tue, 5 Mar 2024 11:00:29 -0500 Subject: [PATCH] Add archive media apis. --- .../attachments/DatabaseAttachment.kt | 9 +- .../securesms/backup/v2/BackupRepository.kt | 96 +++++++ .../InternalBackupPlaygroundFragment.kt | 262 +++++++++++++++--- .../InternalBackupPlaygroundViewModel.kt | 180 ++++++++++++ .../securesms/database/AttachmentTable.kt | 18 +- .../securesms/database/MediaTable.kt | 3 +- .../sms/UploadDependencyGraphTest.kt | 3 +- .../securesms/database/FakeMessageRecords.kt | 6 +- .../signalservice/api/archive/ArchiveApi.kt | 55 +++- .../archive/ArchiveCredentialPresentation.kt | 11 +- .../api/archive/ArchiveMediaRequest.kt | 25 ++ .../api/archive/ArchiveMediaResponse.kt | 15 + .../api/archive/BatchArchiveMediaRequest.kt | 15 + .../api/archive/BatchArchiveMediaResponse.kt | 22 ++ .../api/archive/DeleteArchivedMediaRequest.kt | 20 ++ .../signalservice/api/backup/BackupKey.kt | 25 +- .../signalservice/api/backup/MediaId.kt | 23 ++ .../internal/push/PushServiceSocket.java | 59 +++- 18 files changed, 785 insertions(+), 62 deletions(-) create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaRequest.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaResponse.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaRequest.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaResponse.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/DeleteArchivedMediaRequest.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/MediaId.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt index fa1570a529..1fb11a06e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt @@ -22,6 +22,9 @@ class DatabaseAttachment : Attachment { @JvmField val hasData: Boolean + @JvmField + val dataHash: String? + private val hasThumbnail: Boolean val displayOrder: Int @@ -53,7 +56,8 @@ class DatabaseAttachment : Attachment { audioHash: AudioHash?, transformProperties: TransformProperties?, displayOrder: Int, - uploadTimestamp: Long + uploadTimestamp: Long, + dataHash: String? ) : super( contentType = contentType!!, transferState = transferProgress, @@ -81,6 +85,7 @@ class DatabaseAttachment : Attachment { this.attachmentId = attachmentId this.mmsId = mmsId this.hasData = hasData + this.dataHash = dataHash this.hasThumbnail = hasThumbnail this.displayOrder = displayOrder } @@ -88,6 +93,7 @@ class DatabaseAttachment : Attachment { constructor(parcel: Parcel) : super(parcel) { attachmentId = ParcelCompat.readParcelable(parcel, AttachmentId::class.java.classLoader, AttachmentId::class.java)!! hasData = ParcelUtil.readBoolean(parcel) + dataHash = parcel.readString() hasThumbnail = ParcelUtil.readBoolean(parcel) mmsId = parcel.readLong() displayOrder = parcel.readInt() @@ -97,6 +103,7 @@ class DatabaseAttachment : Attachment { super.writeToParcel(dest, flags) dest.writeParcelable(attachmentId, 0) ParcelUtil.writeBoolean(dest, hasData) + dest.writeString(dataHash) ParcelUtil.writeBoolean(dest, hasThumbnail) dest.writeLong(mmsId) dest.writeInt(displayOrder) 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 31bf07694a..506058880e 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 @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.backup.v2 +import org.signal.core.util.Base64 import org.signal.core.util.EventTimer import org.signal.core.util.logging.Log import org.signal.core.util.withinTransaction @@ -13,6 +14,7 @@ import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult import org.signal.libsignal.messagebackup.MessageBackupKey import org.signal.libsignal.protocol.ServiceId.Aci import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor @@ -33,9 +35,17 @@ import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse +import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest +import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential +import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse +import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest +import org.whispersystems.signalservice.api.backup.BackupKey +import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.ServiceId.PNI +import org.whispersystems.signalservice.internal.crypto.PaddingInputStream import java.io.ByteArrayOutputStream import java.io.InputStream import kotlin.time.Duration.Companion.milliseconds @@ -271,6 +281,77 @@ object BackupRepository { .also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success } + /** + * Returns an object with details about the remote backup state. + */ + fun debugGetArchivedMediaState(): NetworkResult> { + val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi + val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() + + return api + .triggerBackupIdReservation(backupKey) + .then { getAuthCredential() } + .then { credential -> + api.debugGetUploadedMediaItemMetadata(backupKey, credential) + } + } + + fun archiveMedia(attachment: DatabaseAttachment): NetworkResult { + val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi + val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() + + return api + .triggerBackupIdReservation(backupKey) + .then { getAuthCredential() } + .then { credential -> + api.archiveAttachmentMedia( + backupKey = backupKey, + serviceCredential = credential, + item = attachment.toArchiveMediaRequest(backupKey) + ) + } + .also { Log.i(TAG, "backupMediaResult: $it") } + } + + fun archiveMedia(attachments: List): NetworkResult { + val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi + val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() + + return api + .triggerBackupIdReservation(backupKey) + .then { getAuthCredential() } + .then { credential -> + api.archiveAttachmentMedia( + backupKey = backupKey, + serviceCredential = credential, + items = attachments.map { it.toArchiveMediaRequest(backupKey) } + ) + } + .also { Log.i(TAG, "backupMediaResult: $it") } + } + + fun deleteArchivedMedia(attachments: List): NetworkResult { + val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi + val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() + + val mediaToDelete = attachments.map { + DeleteArchivedMediaRequest.ArchivedMediaObject( + cdn = 3, // TODO [cody] store and reuse backup cdn returned from copy/move call + mediaId = backupKey.deriveMediaId(Base64.decode(it.dataHash!!)).toString() + ) + } + + return getAuthCredential() + .then { credential -> + api.deleteArchivedMedia( + backupKey = backupKey, + serviceCredential = credential, + mediaToDelete = mediaToDelete + ) + } + .also { Log.i(TAG, "deleteBackupMediaResult: $it") } + } + /** * Retrieves an auth credential, preferring a cached value if available. */ @@ -298,6 +379,21 @@ object BackupRepository { val e164: String, val profileKey: ProfileKey ) + + private fun DatabaseAttachment.toArchiveMediaRequest(backupKey: BackupKey): ArchiveMediaRequest { + val mediaSecrets = backupKey.deriveMediaSecrets(Base64.decode(dataHash!!)) + return ArchiveMediaRequest( + sourceAttachment = ArchiveMediaRequest.SourceAttachment( + cdn = cdnNumber, + key = remoteLocation!! + ), + objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size)).toInt(), + mediaId = mediaSecrets.id.toString(), + hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey), + encryptionKey = Base64.encodeWithPadding(mediaSecrets.cipherKey), + iv = Base64.encodeWithPadding(mediaSecrets.iv) + ) + } } class ExportState { 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 9d3fd8410a..b1ece67d5d 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 @@ -12,7 +12,11 @@ import android.os.Bundle import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -20,12 +24,26 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -34,6 +52,7 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.viewModels import org.signal.core.ui.Buttons import org.signal.core.ui.Dividers +import org.signal.core.ui.Snackbars import org.signal.core.ui.theme.SignalTheme import org.signal.core.util.bytes import org.signal.core.util.getLength @@ -88,47 +107,95 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { @Composable override fun FragmentContent() { val state by viewModel.state + val mediaState by viewModel.mediaState - Screen( - state = state, - onExportClicked = { viewModel.export() }, - onImportMemoryClicked = { viewModel.import() }, - onImportFileClicked = { - val intent = Intent().apply { - action = Intent.ACTION_GET_CONTENT - type = "application/octet-stream" - addCategory(Intent.CATEGORY_OPENABLE) - } + LaunchedEffect(Unit) { + viewModel.loadMedia() + } - importFileLauncher.launch(intent) + Tabs( + mainContent = { + Screen( + state = state, + onExportClicked = { viewModel.export() }, + onImportMemoryClicked = { viewModel.import() }, + onImportFileClicked = { + val intent = Intent().apply { + action = Intent.ACTION_GET_CONTENT + type = "application/octet-stream" + addCategory(Intent.CATEGORY_OPENABLE) + } + + importFileLauncher.launch(intent) + }, + onPlaintextClicked = { viewModel.onPlaintextToggled() }, + onSaveToDiskClicked = { + val intent = Intent().apply { + action = Intent.ACTION_CREATE_DOCUMENT + type = "application/octet-stream" + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin") + } + + exportFileLauncher.launch(intent) + }, + onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() }, + onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() }, + onValidateFileClicked = { + val intent = Intent().apply { + action = Intent.ACTION_GET_CONTENT + type = "application/octet-stream" + addCategory(Intent.CATEGORY_OPENABLE) + } + + validateFileLauncher.launch(intent) + } + ) }, - onPlaintextClicked = { viewModel.onPlaintextToggled() }, - onSaveToDiskClicked = { - val intent = Intent().apply { - action = Intent.ACTION_CREATE_DOCUMENT - type = "application/octet-stream" - addCategory(Intent.CATEGORY_OPENABLE) - putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin") - } - - exportFileLauncher.launch(intent) - }, - onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() }, - onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() }, - onValidateFileClicked = { - val intent = Intent().apply { - action = Intent.ACTION_GET_CONTENT - type = "application/octet-stream" - addCategory(Intent.CATEGORY_OPENABLE) - } - - validateFileLauncher.launch(intent) + mediaContent = { snackbarHostState -> + MediaList( + state = mediaState, + snackbarHostState = snackbarHostState, + backupAttachmentMedia = { viewModel.backupAttachmentMedia(it) }, + deleteBackupAttachmentMedia = { viewModel.deleteBackupAttachmentMedia(it) }, + batchBackupAttachmentMedia = { viewModel.backupAttachmentMedia(it) }, + batchDeleteBackupAttachmentMedia = { viewModel.deleteBackupAttachmentMedia(it) } + ) } ) } +} - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) +@Composable +fun Tabs( + mainContent: @Composable () -> Unit, + mediaContent: @Composable (snackbarHostState: SnackbarHostState) -> Unit +) { + val tabs = listOf("Main", "Media") + var tabIndex by remember { mutableIntStateOf(0) } + + val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } + + Scaffold( + snackbarHost = { Snackbars.Host(snackbarHostState) }, + topBar = { + TabRow(selectedTabIndex = tabIndex) { + tabs.forEachIndexed { index, tab -> + Tab( + text = { Text(tab) }, + selected = index == tabIndex, + onClick = { tabIndex = index } + ) + } + } + } + ) { + Surface(modifier = Modifier.padding(it)) { + when (tabIndex) { + 0 -> mainContent() + 1 -> mediaContent(snackbarHostState) + } + } } } @@ -198,9 +265,11 @@ fun Screen( BackupState.NONE -> { StateLabel("") } + BackupState.EXPORT_IN_PROGRESS -> { StateLabel("Export in progress...") } + BackupState.EXPORT_DONE -> { StateLabel("Export complete. Sitting in memory. You can click 'Import' to import that data, save it to a file, or upload it to remote.") @@ -210,6 +279,7 @@ fun Screen( Text("Save to file") } } + BackupState.IMPORT_IN_PROGRESS -> { StateLabel("Import in progress...") } @@ -229,12 +299,15 @@ fun Screen( is InternalBackupPlaygroundViewModel.RemoteBackupState.Available -> { StateLabel("Exists/allocated. ${state.remoteBackupState.response.mediaCount} media items, using ${state.remoteBackupState.response.usedSpace} bytes (${state.remoteBackupState.response.usedSpace.bytes.inMebiBytes.roundedString(3)} MiB)") } + InternalBackupPlaygroundViewModel.RemoteBackupState.GeneralError -> { StateLabel("Hit an unknown error. Check the logs.") } + InternalBackupPlaygroundViewModel.RemoteBackupState.NotFound -> { StateLabel("Not found.") } + InternalBackupPlaygroundViewModel.RemoteBackupState.Unknown -> { StateLabel("Hit the button above to check the state.") } @@ -255,12 +328,15 @@ fun Screen( BackupUploadState.NONE -> { StateLabel("") } + BackupUploadState.UPLOAD_IN_PROGRESS -> { StateLabel("Upload in progress...") } + BackupUploadState.UPLOAD_DONE -> { StateLabel("Upload complete.") } + BackupUploadState.UPLOAD_FAILED -> { StateLabel("Upload failed.") } @@ -278,6 +354,124 @@ private fun StateLabel(text: String) { ) } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MediaList( + state: InternalBackupPlaygroundViewModel.MediaState, + snackbarHostState: SnackbarHostState, + backupAttachmentMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit, + deleteBackupAttachmentMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit, + batchBackupAttachmentMedia: (Set) -> Unit, + batchDeleteBackupAttachmentMedia: (Set) -> Unit +) { + LaunchedEffect(state.error?.id) { + state.error?.let { + snackbarHostState.showSnackbar(it.errorText) + } + } + + var selectionState by remember { mutableStateOf(MediaMultiSelectState()) } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items( + count = state.attachments.size, + key = { index -> state.attachments[index].id } + ) { index -> + val attachment = state.attachments[index] + Row( + modifier = Modifier + .combinedClickable( + onClick = { + if (selectionState.selecting) { + selectionState = selectionState.copy(selected = if (selectionState.selected.contains(attachment.mediaId)) selectionState.selected - attachment.mediaId else selectionState.selected + attachment.mediaId) + } + }, + onLongClick = { + selectionState = if (selectionState.selecting) MediaMultiSelectState() else MediaMultiSelectState(selecting = true, selected = setOf(attachment.mediaId)) + } + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + if (selectionState.selecting) { + Checkbox( + checked = selectionState.selected.contains(attachment.mediaId), + onCheckedChange = { selected -> + selectionState = selectionState.copy(selected = if (selected) selectionState.selected + attachment.mediaId else selectionState.selected - attachment.mediaId) + } + ) + } + + Column(modifier = Modifier.weight(1f, true)) { + Text(text = "Attachment ${attachment.title}") + Text(text = "State: ${attachment.state}") + } + + if (attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.INIT || + attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.IN_PROGRESS + ) { + CircularProgressIndicator() + } else { + Button( + enabled = !selectionState.selecting, + onClick = { + when (attachment.state) { + InternalBackupPlaygroundViewModel.BackupAttachment.State.LOCAL_ONLY -> backupAttachmentMedia(attachment) + InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED -> deleteBackupAttachmentMedia(attachment) + else -> throw AssertionError("Unsupported state: ${attachment.state}") + } + } + ) { + Text( + text = when (attachment.state) { + InternalBackupPlaygroundViewModel.BackupAttachment.State.LOCAL_ONLY -> "Backup" + InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED -> "Remote Delete" + else -> throw AssertionError("Unsupported state: ${attachment.state}") + } + ) + } + } + } + } + } + + if (selectionState.selecting) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(8.dp) + ) + .padding(8.dp) + ) { + Button(onClick = { selectionState = MediaMultiSelectState() }) { + Text("Cancel") + } + Button(onClick = { + batchBackupAttachmentMedia(selectionState.selected) + selectionState = MediaMultiSelectState() + }) { + Text("Backup") + } + Button(onClick = { + batchDeleteBackupAttachmentMedia(selectionState.selected) + selectionState = MediaMultiSelectState() + }) { + Text("Delete") + } + } + } + } +} + +private data class MediaMultiSelectState( + val selecting: Boolean = false, + val selected: Set = emptySet() +) + @Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable 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 a178b178ef..c785ef55fc 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 @@ -13,17 +13,27 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.Base64 import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.backup.v2.BackupMetadata import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.backup.BackupKey import java.io.ByteArrayInputStream import java.io.InputStream +import java.util.UUID +import kotlin.random.Random class InternalBackupPlaygroundViewModel : ViewModel() { + private val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() + var backupData: ByteArray? = null val disposables = CompositeDisposable() @@ -31,6 +41,9 @@ class InternalBackupPlaygroundViewModel : ViewModel() { private val _state: MutableState = mutableStateOf(ScreenState(backupState = BackupState.NONE, uploadState = BackupUploadState.NONE, plaintext = false)) val state: State = _state + private val _mediaState: MutableState = mutableStateOf(MediaState()) + val mediaState: State = _mediaState + fun export() { _state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS) val plaintext = _state.value.plaintext @@ -117,9 +130,11 @@ class InternalBackupPlaygroundViewModel : ViewModel() { result is NetworkResult.Success -> { _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Available(result.result)) } + result is NetworkResult.StatusCodeError && result.code == 404 -> { _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.NotFound) } + else -> { _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.GeneralError) } @@ -127,6 +142,98 @@ class InternalBackupPlaygroundViewModel : ViewModel() { } } + fun loadMedia() { + disposables += Single + .fromCallable { SignalDatabase.attachments.debugGetLatestAttachments() } + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.single()) + .subscribeBy { + _mediaState.set { update(attachments = it.map { a -> BackupAttachment.from(backupKey, a) }) } + } + + disposables += Single + .fromCallable { BackupRepository.debugGetArchivedMediaState() } + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.single()) + .subscribeBy { result -> + when (result) { + is NetworkResult.Success -> _mediaState.set { update(archiveStateLoaded = true, backedUpMediaIds = result.result.map { it.mediaId }.toSet()) } + else -> _mediaState.set { copy(error = MediaStateError(errorText = "$result")) } + } + } + } + + fun backupAttachmentMedia(mediaIds: Set) { + disposables += Single.fromCallable { mediaIds.mapNotNull { mediaState.value.idToAttachment[it]?.dbAttachment }.toList() } + .map { BackupRepository.archiveMedia(it) } + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.single()) + .doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + mediaIds) } } + .doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - mediaIds) } } + .subscribeBy { result -> + when (result) { + is NetworkResult.Success -> { + val response = result.result + val successes = response.responses.filter { it.status == 200 } + val failures = response.responses - successes.toSet() + + _mediaState.set { + var updated = update(backedUpMediaIds = backedUpMediaIds + successes.map { it.mediaId }) + if (failures.isNotEmpty()) { + updated = updated.copy(error = MediaStateError(errorText = failures.toString())) + } + updated + } + } + + else -> _mediaState.set { copy(error = MediaStateError(errorText = "$result")) } + } + } + } + + fun backupAttachmentMedia(attachment: BackupAttachment) { + disposables += Single.fromCallable { BackupRepository.archiveMedia(attachment.dbAttachment) } + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.single()) + .doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + attachment.mediaId) } } + .doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - attachment.mediaId) } } + .subscribeBy { + when (it) { + is NetworkResult.Success -> { + _mediaState.set { update(backedUpMediaIds = backedUpMediaIds + attachment.mediaId) } + } + + else -> _mediaState.set { copy(error = MediaStateError(errorText = "$it")) } + } + } + } + + fun deleteBackupAttachmentMedia(mediaIds: Set) { + deleteBackupAttachmentMedia(mediaIds.mapNotNull { mediaState.value.idToAttachment[it] }.toList()) + } + + fun deleteBackupAttachmentMedia(attachment: BackupAttachment) { + deleteBackupAttachmentMedia(listOf(attachment)) + } + + private fun deleteBackupAttachmentMedia(attachments: List) { + val ids = attachments.map { it.mediaId }.toSet() + disposables += Single.fromCallable { BackupRepository.deleteArchivedMedia(attachments.map { it.dbAttachment }) } + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.single()) + .doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + ids) } } + .doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - ids) } } + .subscribeBy { + when (it) { + is NetworkResult.Success -> { + _mediaState.set { update(backedUpMediaIds = backedUpMediaIds - ids) } + } + + else -> _mediaState.set { copy(error = MediaStateError(errorText = "$it")) } + } + } + } + override fun onCleared() { disposables.clear() } @@ -152,4 +259,77 @@ class InternalBackupPlaygroundViewModel : ViewModel() { object GeneralError : RemoteBackupState() data class Available(val response: BackupMetadata) : RemoteBackupState() } + + data class MediaState( + val backupStateLoaded: Boolean = false, + val attachments: List = emptyList(), + val backedUpMediaIds: Set = emptySet(), + val inProgressMediaIds: Set = emptySet(), + val error: MediaStateError? = null + ) { + val idToAttachment: Map = attachments.associateBy { it.mediaId } + + fun update( + archiveStateLoaded: Boolean = this.backupStateLoaded, + attachments: List = this.attachments, + backedUpMediaIds: Set = this.backedUpMediaIds, + inProgressMediaIds: Set = this.inProgressMediaIds + ): MediaState { + val updatedAttachments = if (archiveStateLoaded) { + attachments.map { + val state = if (inProgressMediaIds.contains(it.mediaId)) { + BackupAttachment.State.IN_PROGRESS + } else if (backedUpMediaIds.contains(it.mediaId)) { + BackupAttachment.State.UPLOADED + } else { + BackupAttachment.State.LOCAL_ONLY + } + + it.copy(state = state) + } + } else { + attachments + } + + return copy( + backupStateLoaded = archiveStateLoaded, + attachments = updatedAttachments, + backedUpMediaIds = backedUpMediaIds + ) + } + } + + data class BackupAttachment( + val dbAttachment: DatabaseAttachment, + val state: State = State.INIT, + val mediaId: String = Base64.encodeUrlSafeWithPadding(Random.nextBytes(15)) + ) { + val id: Any = dbAttachment.attachmentId + val title: String = dbAttachment.attachmentId.toString() + + enum class State { + INIT, + LOCAL_ONLY, + UPLOADED, + IN_PROGRESS + } + + companion object { + fun from(backupKey: BackupKey, dbAttachment: DatabaseAttachment): BackupAttachment { + return BackupAttachment( + dbAttachment = dbAttachment, + mediaId = backupKey.deriveMediaId(Base64.decode(dbAttachment.dataHash!!)).toString() + ) + } + } + } + + data class MediaStateError( + val id: UUID = UUID.randomUUID(), + val errorText: String + ) + + fun MutableState.set(update: T.() -> T) { + this.value = this.value.update() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index e3dfcb192a..93d2c6e02d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -949,7 +949,8 @@ class AttachmentTable( audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(jsonObject.getString(BLUR_HASH)) else null, transformProperties = TransformProperties.parse(jsonObject.getString(TRANSFORM_PROPERTIES)), displayOrder = jsonObject.getInt(DISPLAY_ORDER), - uploadTimestamp = jsonObject.getLong(UPLOAD_TIMESTAMP) + uploadTimestamp = jsonObject.getLong(UPLOAD_TIMESTAMP), + dataHash = jsonObject.getString(DATA_HASH) ) } } @@ -1456,7 +1457,8 @@ class AttachmentTable( audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(cursor.requireString(BLUR_HASH)) else null, transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)), displayOrder = cursor.requireInt(DISPLAY_ORDER), - uploadTimestamp = cursor.requireLong(UPLOAD_TIMESTAMP) + uploadTimestamp = cursor.requireLong(UPLOAD_TIMESTAMP), + dataHash = cursor.requireString(DATA_HASH) ) } @@ -1490,6 +1492,18 @@ class AttachmentTable( } } + fun debugGetLatestAttachments(): List { + return readableDatabase + .select(*PROJECTION) + .from(TABLE_NAME) + .where("$TRANSFER_STATE == $TRANSFER_PROGRESS_DONE AND $REMOTE_LOCATION IS NOT NULL AND $DATA_HASH IS NOT NULL") + .orderBy("$ID DESC") + .limit(30) + .run() + .readToList { it.readAttachments() } + .flatten() + } + @VisibleForTesting class DataInfo( val file: File, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt index cc137bcbea..65d73b54d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt @@ -48,7 +48,8 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD ${AttachmentTable.TABLE_NAME}.${AttachmentTable.CAPTION}, ${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP}, ${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST}, - ${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE}, + ${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE}, + ${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_HASH}, ${MessageTable.TABLE_NAME}.${MessageTable.TYPE}, ${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT}, ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED}, diff --git a/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt b/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt index 6cc5d63d4e..c2614d0547 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt @@ -255,7 +255,8 @@ class UploadDependencyGraphTest { audioHash = attachment.audioHash, transformProperties = attachment.transformProperties, displayOrder = 0, - uploadTimestamp = attachment.uploadTimestamp + uploadTimestamp = attachment.uploadTimestamp, + dataHash = null ) } diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index fff425ea77..ebaeba6e9b 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -55,7 +55,8 @@ object FakeMessageRecords { audioHash: AudioHash? = null, transformProperties: AttachmentTable.TransformProperties? = null, displayOrder: Int = 0, - uploadTimestamp: Long = 200 + uploadTimestamp: Long = 200, + dataHash: String? = null ): DatabaseAttachment { return DatabaseAttachment( attachmentId, @@ -85,7 +86,8 @@ object FakeMessageRecords { audioHash, transformProperties, displayOrder, - uploadTimestamp + uploadTimestamp, + dataHash ) } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt index aa00e4cc33..1e43a67feb 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt @@ -125,7 +125,7 @@ class ArchiveApi( * Retrieves all media items in the user's archive. Note that this could be a very large number of items, making this only suitable for debugging. * Use [getArchiveMediaItemsPage] in production. */ - fun debugGetUploadedMediaItemMetadata(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult> { + fun debugGetUploadedMediaItemMetadata(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult> { return NetworkResult.fromFetch { val zkCredential = getZkCredential(backupKey, serviceCredential) val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams) @@ -154,7 +154,58 @@ class ArchiveApi( val zkCredential = getZkCredential(backupKey, serviceCredential) val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams) - pushServiceSocket.getArchiveMediaItemsPage(presentationData.toArchiveCredentialPresentation(), 512, cursor) + pushServiceSocket.getArchiveMediaItemsPage(presentationData.toArchiveCredentialPresentation(), limit, cursor) + } + } + + /** + * Copy and re-encrypt media from the attachments cdn into the backup cdn. + */ + fun archiveAttachmentMedia( + backupKey: BackupKey, + serviceCredential: ArchiveServiceCredential, + item: ArchiveMediaRequest + ): NetworkResult { + return NetworkResult.fromFetch { + val zkCredential = getZkCredential(backupKey, serviceCredential) + val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams) + + pushServiceSocket.archiveAttachmentMedia(presentationData.toArchiveCredentialPresentation(), item) + } + } + + /** + * Copy and re-encrypt media from the attachments cdn into the backup cdn. + */ + fun archiveAttachmentMedia( + backupKey: BackupKey, + serviceCredential: ArchiveServiceCredential, + items: List + ): NetworkResult { + return NetworkResult.fromFetch { + val zkCredential = getZkCredential(backupKey, serviceCredential) + val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams) + + val request = BatchArchiveMediaRequest(items = items) + + pushServiceSocket.archiveAttachmentMedia(presentationData.toArchiveCredentialPresentation(), request) + } + } + + /** + * Delete media from the backup cdn. + */ + fun deleteArchivedMedia( + backupKey: BackupKey, + serviceCredential: ArchiveServiceCredential, + mediaToDelete: List + ): NetworkResult { + return NetworkResult.fromFetch { + val zkCredential = getZkCredential(backupKey, serviceCredential) + val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams) + val request = DeleteArchivedMediaRequest(mediaToDelete = mediaToDelete) + + pushServiceSocket.deleteArchivedMedia(presentationData.toArchiveCredentialPresentation(), request) } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveCredentialPresentation.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveCredentialPresentation.kt index de6e4f585b..29bc7cdde9 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveCredentialPresentation.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveCredentialPresentation.kt @@ -5,10 +5,19 @@ package org.whispersystems.signalservice.api.archive +import org.signal.core.util.Base64 + /** * Acts as credentials for various archive operations. */ class ArchiveCredentialPresentation( val presentation: ByteArray, val signedPresentation: ByteArray -) +) { + fun toHeaders(): MutableMap { + return mutableMapOf( + "X-Signal-ZK-Auth" to Base64.encodeWithPadding(presentation), + "X-Signal-ZK-Auth-Signature" to Base64.encodeWithPadding(signedPresentation) + ) + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaRequest.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaRequest.kt new file mode 100644 index 0000000000..3ed0a8c00d --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaRequest.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.archive + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Request to copy and re-encrypt media from the attachments cdn into the backup cdn. + */ +class ArchiveMediaRequest( + @JsonProperty val sourceAttachment: SourceAttachment, + @JsonProperty val objectLength: Int, + @JsonProperty val mediaId: String, + @JsonProperty val hmacKey: String, + @JsonProperty val encryptionKey: String, + @JsonProperty val iv: String +) { + class SourceAttachment( + @JsonProperty val cdn: Int, + @JsonProperty val key: String + ) +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaResponse.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaResponse.kt new file mode 100644 index 0000000000..7cdfa650dd --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaResponse.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.archive + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Response to archiving media, backup CDN number where media is located. + */ +class ArchiveMediaResponse( + @JsonProperty val cdn: Int +) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaRequest.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaRequest.kt new file mode 100644 index 0000000000..3f4822b547 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaRequest.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.archive + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Request to copy and re-encrypt media from the attachments cdn into the backup cdn. + */ +class BatchArchiveMediaRequest( + @JsonProperty val items: List +) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaResponse.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaResponse.kt new file mode 100644 index 0000000000..2fb002e4cf --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaResponse.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.archive + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Multi-response data for a batch archive media operation. + */ +class BatchArchiveMediaResponse( + @JsonProperty val responses: List +) { + class BatchArchiveMediaItemResponse( + @JsonProperty val status: Int?, + @JsonProperty val failureReason: String?, + @JsonProperty val cdn: Int?, + @JsonProperty val mediaId: String + ) +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/DeleteArchivedMediaRequest.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/DeleteArchivedMediaRequest.kt new file mode 100644 index 0000000000..4d9a5d73b3 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/DeleteArchivedMediaRequest.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.archive + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Delete media from the backup cdn. + */ +class DeleteArchivedMediaRequest( + @JsonProperty val mediaToDelete: List +) { + class ArchivedMediaObject( + @JsonProperty val cdn: Int, + @JsonProperty val mediaId: String + ) +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/BackupKey.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/BackupKey.kt index 48343a0e82..1939cc5e1b 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/BackupKey.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/BackupKey.kt @@ -16,7 +16,7 @@ class BackupKey(val value: ByteArray) { require(value.size == 32) { "Backup key must be 32 bytes!" } } - fun deriveSecrets(aci: ACI): KeyMaterial { + fun deriveSecrets(aci: ACI): KeyMaterial { val backupId = BackupId( HKDF.deriveSecrets(this.value, aci.toByteArray(), "20231003_Signal_Backups_GenerateBackupId".toByteArray(), 16) ) @@ -24,15 +24,32 @@ class BackupKey(val value: ByteArray) { val extendedKey = HKDF.deriveSecrets(this.value, backupId.value, "20231003_Signal_Backups_EncryptMessageBackup".toByteArray(), 80) return KeyMaterial( - backupId = backupId, + id = backupId, macKey = extendedKey.copyOfRange(0, 32), cipherKey = extendedKey.copyOfRange(32, 64), iv = extendedKey.copyOfRange(64, 80) ) } - class KeyMaterial( - val backupId: BackupId, + fun deriveMediaId(dataHash: ByteArray): MediaId { + return MediaId(HKDF.deriveSecrets(value, dataHash, "Media ID".toByteArray(), 15)) + } + + fun deriveMediaSecrets(dataHash: ByteArray): KeyMaterial { + val mediaId = deriveMediaId(dataHash) + + val extendedKey = HKDF.deriveSecrets(this.value, mediaId.value, "20231003_Signal_Backups_EncryptMedia".toByteArray(), 80) + + return KeyMaterial( + id = mediaId, + macKey = extendedKey.copyOfRange(0, 32), + cipherKey = extendedKey.copyOfRange(32, 64), + iv = extendedKey.copyOfRange(64, 80) + ) + } + + class KeyMaterial ( + val id: Id, val macKey: ByteArray, val cipherKey: ByteArray, val iv: ByteArray diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/MediaId.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/MediaId.kt new file mode 100644 index 0000000000..6575e99118 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/MediaId.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.backup + +import org.signal.core.util.Base64 + +/** + * Safe typing around a mediaId, which is a 15-byte array. + */ +@JvmInline +value class MediaId(val value: ByteArray) { + + init { + require(value.size == 15) { "MediaId must be 15 bytes!" } + } + + override fun toString(): String { + return Base64.encodeUrlSafeWithPadding(value) + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index b2f6a3991d..92331c5493 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -19,7 +19,6 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.kem.KEMPublicKey; import org.signal.libsignal.protocol.logging.Log; import org.signal.libsignal.protocol.state.PreKeyBundle; -import org.signal.libsignal.protocol.state.SignedPreKeyRecord; import org.signal.libsignal.protocol.util.Pair; import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.Username; @@ -50,10 +49,15 @@ import org.whispersystems.signalservice.api.account.PreKeyUpload; import org.whispersystems.signalservice.api.archive.ArchiveCredentialPresentation; import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse; import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse; +import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest; +import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse; import org.whispersystems.signalservice.api.archive.ArchiveMessageBackupUploadFormResponse; import org.whispersystems.signalservice.api.archive.ArchiveServiceCredentialsResponse; import org.whispersystems.signalservice.api.archive.ArchiveSetBackupIdRequest; import org.whispersystems.signalservice.api.archive.ArchiveSetPublicKeyRequest; +import org.whispersystems.signalservice.api.archive.BatchArchiveMediaRequest; +import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse; +import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; import org.whispersystems.signalservice.api.groupsv2.CredentialResponse; import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; @@ -305,11 +309,15 @@ public class PushServiceSocket { private static final String BACKUP_AUTH_CHECK = "/v2/backup/auth/check"; private static final String ARCHIVE_CREDENTIALS = "/v1/archives/auth?redemptionStartSeconds=%d&redemptionEndSeconds=%d"; + private static final String ARCHIVE_READ_CREDENTIALS = "/v1/archives/auth/read"; private static final String ARCHIVE_BACKUP_ID = "/v1/archives/backupid"; private static final String ARCHIVE_PUBLIC_KEY = "/v1/archives/keys"; private static final String ARCHIVE_INFO = "/v1/archives"; private static final String ARCHIVE_MESSAGE_UPLOAD_FORM = "/v1/archives/upload/form"; + private static final String ARCHIVE_MEDIA = "/v1/archives/media"; private static final String ARCHIVE_MEDIA_LIST = "/v1/archives/media?limit=%d"; + private static final String ARCHIVE_MEDIA_BATCH = "/v1/archives/media/batch"; + private static final String ARCHIVE_MEDIA_DELETE = "/v1/archives/media/delete"; private static final String CALL_LINK_CREATION_AUTH = "/v1/call-link/create-auth"; private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp"; @@ -494,18 +502,14 @@ public class PushServiceSocket { } public void setArchivePublicKey(ECPublicKey publicKey, ArchiveCredentialPresentation credentialPresentation) throws IOException { - Map headers = new HashMap<>(); - headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation())); - headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation())); + Map headers = credentialPresentation.toHeaders(); String body = JsonUtil.toJson(new ArchiveSetPublicKeyRequest(publicKey)); makeServiceRequestWithoutAuthentication(ARCHIVE_PUBLIC_KEY, "PUT", body, headers, NO_HANDLER); } public ArchiveGetBackupInfoResponse getArchiveBackupInfo(ArchiveCredentialPresentation credentialPresentation) throws IOException { - Map headers = new HashMap<>(); - headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation())); - headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation())); + Map headers = credentialPresentation.toHeaders(); String response = makeServiceRequestWithoutAuthentication(ARCHIVE_INFO, "GET", null, headers, NO_HANDLER); return JsonUtil.fromJson(response, ArchiveGetBackupInfoResponse.class); @@ -529,9 +533,7 @@ public class PushServiceSocket { * @param cursor A token that can be read from your previous response, telling the server where to start the next page. */ public ArchiveGetMediaItemsResponse getArchiveMediaItemsPage(ArchiveCredentialPresentation credentialPresentation, int limit, String cursor) throws IOException { - Map headers = new HashMap<>(); - headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation())); - headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation())); + Map headers = credentialPresentation.toHeaders(); String url = String.format(Locale.US, ARCHIVE_MEDIA_LIST, limit); @@ -544,10 +546,39 @@ public class PushServiceSocket { return JsonUtil.fromJson(response, ArchiveGetMediaItemsResponse.class); } + /** + * Copy and re-encrypt media from the attachments cdn into the backup cdn. + */ + public ArchiveMediaResponse archiveAttachmentMedia(@Nonnull ArchiveCredentialPresentation credentialPresentation, @Nonnull ArchiveMediaRequest request) throws IOException { + Map headers = credentialPresentation.toHeaders(); + + String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MEDIA, "PUT", JsonUtil.toJson(request), headers, NO_HANDLER); + + return JsonUtil.fromJson(response, ArchiveMediaResponse.class); + } + + /** + * Copy and re-encrypt media from the attachments cdn into the backup cdn. + */ + public BatchArchiveMediaResponse archiveAttachmentMedia(@Nonnull ArchiveCredentialPresentation credentialPresentation, @Nonnull BatchArchiveMediaRequest request) throws IOException { + Map headers = credentialPresentation.toHeaders(); + + String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MEDIA_BATCH, "PUT", JsonUtil.toJson(request), headers, NO_HANDLER); + + return JsonUtil.fromJson(response, BatchArchiveMediaResponse.class); + } + + /** + * Delete media from the backup cdn. + */ + public void deleteArchivedMedia(@Nonnull ArchiveCredentialPresentation credentialPresentation, @Nonnull DeleteArchivedMediaRequest request) throws IOException { + Map headers = credentialPresentation.toHeaders(); + + makeServiceRequestWithoutAuthentication(ARCHIVE_MEDIA_DELETE, "POST", JsonUtil.toJson(request), headers, NO_HANDLER); + } + public ArchiveMessageBackupUploadFormResponse getArchiveMessageBackupUploadForm(ArchiveCredentialPresentation credentialPresentation) throws IOException { - Map headers = new HashMap<>(); - headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation())); - headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation())); + Map headers = credentialPresentation.toHeaders(); String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MESSAGE_UPLOAD_FORM, "GET", null, headers, NO_HANDLER); return JsonUtil.fromJson(response, ArchiveMessageBackupUploadFormResponse.class); @@ -2126,7 +2157,7 @@ public class PushServiceSocket { throw new ServerRejectedException(); } - if (responseCode != 200 && responseCode != 202 && responseCode != 204) { + if (responseCode != 200 && responseCode != 202 && responseCode != 204 && responseCode != 207) { throw new NonSuccessfulResponseCodeException(responseCode, "Bad response: " + responseCode + " " + responseMessage); }