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 01f6f89e6c..14051c61c9 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 @@ -31,11 +31,11 @@ import org.signal.core.models.AccountEntropyPool import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.PNI import org.signal.core.models.backup.BackupId +import org.signal.core.models.backup.MediaId import org.signal.core.models.backup.MediaName import org.signal.core.models.backup.MediaRootBackupKey import org.signal.core.models.backup.MessageBackupKey import org.signal.core.models.database.AttachmentId -import org.signal.core.util.Base64 import org.signal.core.util.Base64.decodeBase64OrThrow import org.signal.core.util.CursorUtil import org.signal.core.util.DiskUtil @@ -66,6 +66,9 @@ import org.signal.core.util.stream.NonClosingOutputStream import org.signal.core.util.urlEncode import org.signal.core.util.withinTransaction import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken +import org.signal.libsignal.net.CopyBackupMediaItem +import org.signal.libsignal.net.CopyBackupMediaOutcome +import org.signal.libsignal.net.DeleteBackupMediaItem import org.signal.libsignal.zkgroup.VerificationFailedException import org.signal.libsignal.zkgroup.backups.BackupLevel import org.signal.libsignal.zkgroup.profiles.ProfileKey @@ -152,12 +155,9 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.toMillis import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse -import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest -import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess import org.whispersystems.signalservice.api.archive.ArchiveServiceAccessPair import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential -import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil import org.whispersystems.signalservice.api.link.TransferArchiveResponse @@ -1626,7 +1626,7 @@ object BackupRepository { fun debugGetRemoteBackupState(): NetworkResult { return initBackupAndFetchAuth() .then { credential -> - SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess) + SignalNetwork.archive.getMediaBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess) .map { it to credential } } .then { pair -> @@ -1634,7 +1634,7 @@ object BackupRepository { SignalNetwork.archive.debugGetUploadedMediaItemMetadata(SignalStore.account.requireAci(), credential.mediaBackupAccess) .map { mediaObjects -> DebugBackupMetadata( - usedSpace = mediaBackupInfo.usedSpace ?: 0, + usedSpace = mediaBackupInfo.usedSpace, mediaCount = mediaObjects.size.toLong(), mediaSize = mediaObjects.sumOf { it.objectLength } ) @@ -1652,26 +1652,26 @@ object BackupRepository { fun downloadBackupFile(destination: File, listener: ProgressListener? = null): NetworkResult { return initBackupAndFetchAuth() .then { credential -> - SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) + SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) } - .then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } } + .then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn).map { it.headers to info } } .map { pair -> val (cdnCredentials, info) = pair val messageReceiver = AppDependencies.signalServiceMessageReceiver - messageReceiver.retrieveBackup(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}", destination, listener) + messageReceiver.retrieveBackup(info.cdn, cdnCredentials, "backups/${info.backupDir}/${info.backupName}", destination, listener) } } fun getBackupFileLastModified(): NetworkResult { return initBackupAndFetchAuth() .then { credential -> - SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) + SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) } - .then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn ?: RemoteConfig.backupFallbackArchiveCdn).map { it.headers to info } } + .then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn).map { it.headers to info } } .then { pair -> val (cdnCredentials, info) = pair NetworkResult.fromFetch { - AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}") + AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn, cdnCredentials, "backups/${info.backupDir}/${info.backupName}") } } } @@ -1737,17 +1737,14 @@ object BackupRepository { /** * Copies a thumbnail that has been uploaded to the transit cdn to the archive cdn. + * + * @return The archive cdn number the thumbnail landed on. */ - fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult { + fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult { return initBackupAndFetchAuth() .then { credential -> - val request = buildArchiveMediaRequest(thumbnail.cdnNumber, thumbnail.remoteLocation, thumbnail.size, parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey) - - SignalNetwork.archive.copyAttachmentToArchive( - aci = SignalStore.account.requireAci(), - archiveServiceAccess = credential.mediaBackupAccess, - item = request - ) + val item = buildCopyBackupMediaItem(thumbnail.cdnNumber, thumbnail.remoteLocation, thumbnail.size, parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey) + copySingleMediaToArchive(credential.mediaBackupAccess, item) } } @@ -1757,43 +1754,32 @@ object BackupRepository { fun copyAttachmentToArchive(attachment: DatabaseAttachment): NetworkResult { return initBackupAndFetchAuth() .then { credential -> - val mediaName = attachment.requireMediaName() - val request = buildArchiveMediaRequest(attachment.cdn.cdnNumber, attachment.remoteLocation!!, attachment.size, mediaName, credential.mediaBackupAccess.backupKey) - SignalNetwork.archive - .copyAttachmentToArchive( - aci = SignalStore.account.requireAci(), - archiveServiceAccess = credential.mediaBackupAccess, - item = request - ) + val item = buildCopyBackupMediaItem(attachment.cdn.cdnNumber, attachment.remoteLocation!!, attachment.size, attachment.requireMediaName(), credential.mediaBackupAccess.backupKey) + copySingleMediaToArchive(credential.mediaBackupAccess, item) } - .map { response -> - SignalDatabase.attachments.setArchiveCdn(attachmentId = attachment.attachmentId, archiveCdn = response.cdn) + .map { archiveCdn -> + SignalDatabase.attachments.setArchiveCdn(attachmentId = attachment.attachmentId, archiveCdn = archiveCdn) } .also { Log.i(TAG, "archiveMediaResult: ${it::class.simpleName}") } } fun deleteAbandonedMediaObjects(mediaObjects: Collection): NetworkResult { - val mediaToDelete = mediaObjects - .map { - DeleteArchivedMediaRequest.ArchivedMediaObject( - cdn = it.cdn, - mediaId = it.mediaId - ) - } - .filter { it.cdn == Cdn.CDN_3.cdnNumber } + return NetworkResult + .fromLocal { mediaObjects.filter { it.cdn == Cdn.CDN_3.cdnNumber }.map { it.toDeleteBackupMediaItem() } } + .then { mediaToDelete -> + if (mediaToDelete.isEmpty()) { + Log.i(TAG, "No media to delete, quick success") + return@then NetworkResult.Success(Unit) + } - if (mediaToDelete.isEmpty()) { - Log.i(TAG, "No media to delete, quick success") - return NetworkResult.Success(Unit) - } - - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.deleteArchivedMedia( - aci = SignalStore.account.requireAci(), - archiveServiceAccess = credential.mediaBackupAccess, - mediaToDelete = mediaToDelete - ) + initBackupAndFetchAuth() + .then { credential -> + SignalNetwork.archive.deleteArchivedMedia( + aci = SignalStore.account.requireAci(), + archiveServiceAccess = credential.mediaBackupAccess, + mediaToDelete = mediaToDelete + ).map { } + } } .also { Log.i(TAG, "deleteAbandonedMediaObjectsResult: ${it::class.simpleName}") } } @@ -1817,13 +1803,8 @@ object BackupRepository { return debugGetArchivedMediaState() .then { archivedMedia -> val mediaChunksToDelete = archivedMedia - .map { - DeleteArchivedMediaRequest.ArchivedMediaObject( - cdn = it.cdn, - mediaId = it.mediaId - ) - } .filter { it.cdn == Cdn.CDN_3.cdnNumber } + .map { ArchivedMediaObject(mediaId = it.mediaId, cdn = it.cdn).toDeleteBackupMediaItem() } .chunked(itemLimit) if (mediaChunksToDelete.isEmpty()) { @@ -1838,7 +1819,7 @@ object BackupRepository { aci = SignalStore.account.requireAci(), archiveServiceAccess = credential.mediaBackupAccess, mediaToDelete = chunk - ) + ).map { } if (result !is NetworkResult.Success) { Log.w(TAG, "Error occurred while deleting archived media chunk #$index: $result") @@ -1975,12 +1956,12 @@ object BackupRepository { } } .then { messageAccess -> - SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), messageAccess) - .then { info -> SignalNetwork.archive.getCdnReadCredentials(info.cdn ?: RemoteConfig.backupFallbackArchiveCdn, aci, messageAccess).map { it.headers to info } } + SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), messageAccess) + .then { info -> SignalNetwork.archive.getCdnReadCredentials(info.cdn, aci, messageAccess).map { it.headers to info } } .then { pair -> val (cdnCredentials, info) = pair NetworkResult.fromFetch { - AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}") + AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn, cdnCredentials, "backups/${info.backupDir}/${info.backupName}") } } } @@ -2026,8 +2007,8 @@ object BackupRepository { return initBackupAndFetchAuth() .then { credential -> - SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess).map { - "${it.backupDir!!.urlEncode()}/${it.mediaDir!!.urlEncode()}" + SignalNetwork.archive.getMediaBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess).map { + "${it.backupDir.urlEncode()}/${it.mediaDir.urlEncode()}" } } .also { @@ -2209,21 +2190,38 @@ object BackupRepository { val profileKey: ProfileKey ) - private fun buildArchiveMediaRequest(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest { + private fun buildCopyBackupMediaItem(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): CopyBackupMediaItem { val mediaSecrets = mediaRootBackupKey.deriveMediaSecrets(mediaName) - return ArchiveMediaRequest( - sourceAttachment = ArchiveMediaRequest.SourceAttachment( - cdn = cdnNumber, - key = remoteLocation - ), - objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)).toInt(), - mediaId = mediaSecrets.id.encode(), - hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey), - encryptionKey = Base64.encodeWithPadding(mediaSecrets.aesKey) + return CopyBackupMediaItem( + sourceAttachmentCdn = cdnNumber, + sourceKey = remoteLocation, + objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)), + mediaId = mediaSecrets.id.value, + encryptionKey = mediaSecrets.macKey + mediaSecrets.aesKey ) } + /** + * Copies a single item to the archive cdn, returning the cdn it landed on. + * + * libsignal reports per-item outcomes rather than status codes, so we translate them back into the status codes callers already branch on: a missing source + * is a 410, a length mismatch is a 400, and no remaining space is a 413. + */ + private fun copySingleMediaToArchive(archiveServiceAccess: ArchiveServiceAccess, item: CopyBackupMediaItem): NetworkResult { + return SignalNetwork.archive + .copyMediaToArchive(SignalStore.account.requireAci(), archiveServiceAccess, listOf(item)) + .then { outcomes -> + when (val outcome = outcomes.firstOrNull()) { + is CopyBackupMediaOutcome.Success -> NetworkResult.Success(outcome.cdn) + is CopyBackupMediaOutcome.SourceNotFound -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(410, "Source attachment not found")) + is CopyBackupMediaOutcome.WrongSourceLength -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(400, "Wrong source length")) + is CopyBackupMediaOutcome.OutOfSpace -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(413, "No media space remaining")) + null -> NetworkResult.ApplicationError(IllegalStateException("Copy stream ended without an outcome for the item!")) + } + } + } + suspend fun restoreRemoteBackup(): RemoteRestoreResult { val context = AppDependencies.application ArchiveRestoreProgress.onRestorePending() @@ -2471,8 +2469,8 @@ object BackupRepository { fun getRemoteBackupForwardSecrecyMetadata(): NetworkResult { return initBackupAndFetchAuth() - .then { credential -> SignalNetwork.archive.getBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) } - .then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } } + .then { credential -> SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) } + .then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn).map { it.headers to info } } .then { pair -> val (cdnCredentials, info) = pair val headers = cdnCredentials.toMutableMap().apply { @@ -2480,7 +2478,7 @@ object BackupRepository { } AppDependencies.signalServiceMessageReceiver.retrieveBackupForwardSecretMetadataBytes( - info.cdn!!, + info.cdn, headers, "backups/${info.backupDir}/${info.backupName}", EncryptedBackupReader.BACKUP_SECRET_METADATA_UPPERBOUND @@ -2511,7 +2509,11 @@ data class ResumableMessagesBackupUploadSpec( val resumableUri: String ) -data class ArchivedMediaObject(val mediaId: String, val cdn: Int) +data class ArchivedMediaObject(val mediaId: String, val cdn: Int) { + fun toDeleteBackupMediaItem(): DeleteBackupMediaItem { + return DeleteBackupMediaItem(mediaId = MediaId(mediaId).value, cdn = cdn) + } +} class ExportState( val backupTime: Long, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BatchArchiveMediaResult.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BatchArchiveMediaResult.kt deleted file mode 100644 index c2effd4c9b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BatchArchiveMediaResult.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.backup.v2 - -import org.signal.core.models.database.AttachmentId -import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse - -/** - * Result of attempting to batch copy multiple attachments at once with helpers for - * processing the collection of mini-responses. - */ -data class BatchArchiveMediaResult( - private val response: BatchArchiveMediaResponse, - private val mediaIdToAttachmentId: Map, - private val attachmentIdToMediaName: Map -) { - val successfulResponses: Sequence - get() = response - .responses - .asSequence() - .filter { it.status == 200 } - - val sourceNotFoundResponses: Sequence - get() = response - .responses - .asSequence() - .filter { it.status == 410 } - - fun mediaIdToAttachmentId(mediaId: String): AttachmentId { - return mediaIdToAttachmentId[mediaId]!! - } - - fun attachmentIdToMediaName(attachmentId: AttachmentId): String { - return attachmentIdToMediaName[attachmentId]!! - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java index 1603d53c82..f7fab37df5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java @@ -111,7 +111,7 @@ public class RefreshAttributesJob extends BaseJob { boolean phoneNumberDiscoverable = SignalStore.phoneNumberPrivacy().getPhoneNumberDiscoverabilityMode() == PhoneNumberDiscoverabilityMode.DISCOVERABLE; Log.i(TAG, "Linked device, refreshing device capabilities and phone number discoverability. Capabilities: " + capabilities + ", discoverable: " + phoneNumberDiscoverable); RequestResultUtil.successOrThrow(SignalNetwork.account().setCapabilities(capabilities)); - RequestResultUtil.successOrThrow(SignalNetwork.account().setPhoneNumberDiscoverability(phoneNumberDiscoverable)); + RequestResultUtil.successOrThrowNoError(SignalNetwork.account().setPhoneNumberDiscoverability(phoneNumberDiscoverable)); } hasRefreshedThisAppCycle = true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt index 2a5dda113b..c10ca217ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt @@ -490,21 +490,21 @@ object LinkDeviceRepository { /** * Changes the name of a linked device and sends a sync message if successful */ - fun changeDeviceName(deviceName: String, deviceId: Int): DeviceNameChangeResult { - val encryptedDeviceName = Base64.encodeWithoutPadding(DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), SignalStore.account.aciIdentityKey)) + suspend fun changeDeviceName(deviceName: String, deviceId: Int): DeviceNameChangeResult { + val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), SignalStore.account.aciIdentityKey) return when (val result = SignalNetwork.linkDevice.setDeviceName(encryptedDeviceName, deviceId)) { - is NetworkResult.Success -> { + is RequestResult.Success -> { AppDependencies.jobManager.add(DeviceNameChangeJob(deviceId)) DeviceNameChangeResult.Success.logI(TAG, "Successfully changed device name") } - is NetworkResult.NetworkError -> { - DeviceNameChangeResult.NetworkError(result.exception).logW(TAG, "Could not change name due to network error.", result.exception) + is RequestResult.NonSuccess -> { + DeviceNameChangeResult.NetworkError(result.error).logW(TAG, "Could not change name because the device could not be found.") } - is NetworkResult.StatusCodeError -> { - DeviceNameChangeResult.NetworkError(result.exception).logW(TAG, "Could not change name due to status code error ${result.code}") + is RequestResult.RetryableNetworkError -> { + DeviceNameChangeResult.NetworkError(result.networkError).logW(TAG, "Could not change name due to network error.", result.networkError) } - is NetworkResult.ApplicationError -> { - throw result.throwable.logW(TAG, "Could not change name due to application error.") + is RequestResult.ApplicationError -> { + throw result.cause.logW(TAG, "Could not change name due to application error.") } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt index 29d414ba9d..cdcec647e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt @@ -29,8 +29,8 @@ import org.thoughtcrime.securesms.megaphone.Megaphones import org.thoughtcrime.securesms.net.SignalNetwork import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet -import org.whispersystems.signalservice.api.NetworkResultUtil import org.whispersystems.signalservice.api.SvrNoDataException +import org.whispersystems.signalservice.api.successOrThrow import org.whispersystems.signalservice.api.svr.SecureValueRecovery import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse @@ -393,7 +393,7 @@ object SvrRepository { check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" } Log.i(TAG, "[enableRegistrationLockForUserWithPin] Enabling registration lock.", true) - NetworkResultUtil.toBasicLegacy(SignalNetwork.account.enableRegistrationLock(SignalStore.svr.masterKey.deriveRegistrationLock())) + SignalNetwork.account.enableRegistrationLock(SignalStore.svr.masterKey).successOrThrow() SignalStore.svr.isRegistrationLockEnabled = true Log.i(TAG, "[enableRegistrationLockForUserWithPin] Registration lock successfully enabled.", true) } @@ -407,7 +407,7 @@ object SvrRepository { check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" } Log.i(TAG, "[disableRegistrationLockForUserWithPin] Disabling registration lock.", true) - NetworkResultUtil.toBasicLegacy(SignalNetwork.account.disableRegistrationLock()) + SignalNetwork.account.disableRegistrationLock().successOrThrow() SignalStore.svr.isRegistrationLockEnabled = false Log.i(TAG, "[disableRegistrationLockForUserWithPin] Registration lock successfully disabled.", true) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt index fb7c79b885..47843c48aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt @@ -12,6 +12,7 @@ import org.signal.core.util.UuidUtil import org.signal.core.util.logging.Log import org.signal.core.util.toByteArray import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.net.RetryLaterException import org.signal.libsignal.usernames.BaseUsernameException import org.signal.libsignal.usernames.Username import org.signal.libsignal.usernames.UsernameLinkInvalidEntropyDataLength @@ -32,6 +33,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.NetworkUtil import org.thoughtcrime.securesms.util.UsernameUtil import org.whispersystems.signalservice.api.SignalServiceAccountManager +import org.whispersystems.signalservice.api.getCause import org.whispersystems.signalservice.api.push.UsernameLinkComponents import org.whispersystems.signalservice.api.util.Usernames import java.util.UUID @@ -208,7 +210,7 @@ object UsernameRepository { val usernameLink = username.generateLink() when (val result = SignalNetwork.account.createUsernameLink(usernameLink)) { - is NetworkResult.Success -> { + is RequestResult.Success -> { SignalStore.account.usernameLink = result.result if (SignalStore.account.usernameSyncState == AccountValues.UsernameSyncState.LINK_CORRUPTED) { @@ -389,12 +391,11 @@ object UsernameRepository { return failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR) } - val hashes: List = candidates - .map { Base64.encodeUrlSafeWithoutPadding(it.hash) } + val hashes: List = candidates.map { it.hash } return when (val result = SignalNetwork.account.reserveUsername(hashes)) { - is NetworkResult.Success -> { - val hashIndex = hashes.indexOf(result.result.usernameHash) + is RequestResult.Success -> { + val hashIndex = hashes.indexOfFirst { it.contentEquals(result.result) } if (hashIndex == -1) { Log.w(TAG, "[reserveUsername] The response hash could not be found in our set of hashes.") return failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR) @@ -403,31 +404,20 @@ object UsernameRepository { Log.i(TAG, "[reserveUsername] Successfully reserved username.") success(UsernameState.Reserved(candidates[hashIndex])) } - is NetworkResult.StatusCodeError -> { - when (result.code) { - 409 -> { - Log.w(TAG, "[reserveUsername] Username taken.") - failure(UsernameSetResult.USERNAME_UNAVAILABLE) - } - 422 -> { - Log.w(TAG, "[reserveUsername] Username malformed.") - failure(UsernameSetResult.USERNAME_INVALID) - } - 429 -> { - Log.w(TAG, "[reserveUsername] Rate limit exceeded.") - failure(UsernameSetResult.RATE_LIMIT_ERROR) - } - else -> { - Log.w(TAG, "[reserveUsername] Generic network exception.", result.exception) - failure(UsernameSetResult.NETWORK_ERROR) - } + is RequestResult.NonSuccess -> { + Log.w(TAG, "[reserveUsername] Username taken.") + failure(UsernameSetResult.USERNAME_UNAVAILABLE) + } + is RequestResult.RetryableNetworkError -> { + if (result.networkError is RetryLaterException) { + Log.w(TAG, "[reserveUsername] Rate limit exceeded.") + failure(UsernameSetResult.RATE_LIMIT_ERROR) + } else { + Log.w(TAG, "[reserveUsername] Generic network exception.", result.networkError) + failure(UsernameSetResult.NETWORK_ERROR) } } - is NetworkResult.NetworkError -> { - Log.w(TAG, "[reserveUsername] Generic network exception.", result.exception) - failure(UsernameSetResult.NETWORK_ERROR) - } - is NetworkResult.ApplicationError -> throw result.throwable + is RequestResult.ApplicationError -> throw result.cause } } @@ -444,7 +434,7 @@ object UsernameRepository { val newUsernameLink = updatedUsername.generateLink(oldUsernameLink.entropy) return when (val result = SignalNetwork.account.updateUsernameLink(newUsernameLink)) { - is NetworkResult.Success -> { + is RequestResult.Success -> { SignalStore.account.username = updatedUsername.username SignalStore.account.usernameLink = result.result SignalDatabase.recipients.setUsername(Recipient.self().id, updatedUsername.username) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index 50bad8ad11..c9d61815c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -296,32 +296,11 @@ class AppRegistrationNetworkController( return@withContext RequestResult.NonSuccess(SetRegistrationLockError.NoPinSet) } - when (val result = SignalNetwork.account.enableRegistrationLock(masterKey.deriveRegistrationLock())) { - is NetworkResult.Success -> RequestResult.Success(Unit) - is NetworkResult.StatusCodeError -> { - when (result.code) { - 401 -> RequestResult.NonSuccess(SetRegistrationLockError.Unauthorized) - 422 -> RequestResult.NonSuccess(SetRegistrationLockError.InvalidRequest(result.toString())) - else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}")) - } - } - is NetworkResult.NetworkError -> RequestResult.RetryableNetworkError(result.exception) - is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable) - } + SignalNetwork.account.enableRegistrationLock(masterKey) } override suspend fun disableRegistrationLock(): RequestResult = withContext(Dispatchers.IO) { - when (val result = SignalNetwork.account.disableRegistrationLock()) { - is NetworkResult.Success -> RequestResult.Success(Unit) - is NetworkResult.StatusCodeError -> { - when (result.code) { - 401 -> RequestResult.NonSuccess(SetRegistrationLockError.Unauthorized) - else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}")) - } - } - is NetworkResult.NetworkError -> RequestResult.RetryableNetworkError(result.exception) - is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable) - } + SignalNetwork.account.disableRegistrationLock() } override suspend fun getSvrCredentials(): RequestResult = withContext(Dispatchers.IO) { @@ -384,26 +363,29 @@ class AppRegistrationNetworkController( } } - when (val result = SignalNetwork.archive.getBackupInfo(aci, access)) { + when (val result = SignalNetwork.archive.getMessageBackupInfo(aci, access)) { is NetworkResult.Success -> { val info = result.result RequestResult.Success( NetworkController.GetBackupInfoResponse( cdn = info.cdn, backupDir = info.backupDir, - mediaDir = info.mediaDir, + // mediaDir and usedSpace live under the media credential, not the message credential we're using here. The server left them empty on this request + // before too, so nothing that reads them is losing a value it used to get. + mediaDir = null, backupName = info.backupName, - usedSpace = info.usedSpace + usedSpace = null ) ) } is NetworkResult.StatusCodeError -> { when (result.code) { - 400 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.BadArguments(result.stringBody)) - 401 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.BadAuthCredential(result.stringBody)) - 403 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.Forbidden(result.stringBody)) - 404 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup) - 429 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.RateLimited(0.seconds)) + 401 -> { + // The server doesn't distinguish an invalid credential from a backup-id that was never provisioned, so libsignal's guidance is to treat this as + // "backups aren't set up" rather than an auth failure. + RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup) + } + 429 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.RateLimited(result.retryAfter() ?: Duration.ZERO)) else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}")) } } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt index 926b9aeab5..3ff4916edb 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/RequestResultExtensions.kt @@ -89,6 +89,20 @@ fun RequestResult.successOrThrow(): T { } } +/** + * Returns the [Throwable] associated with a non-successful result, or null if there isn't one. Mirrors + * [org.signal.network.NetworkResult.getCause] for logging-only call sites that don't care which flavor of failure + * they got. + */ +fun RequestResult<*, *>.getCause(): Throwable? { + return when (this) { + is RequestResult.Success -> null + is RequestResult.NonSuccess -> error as? Throwable + is RequestResult.RetryableNetworkError -> networkError + is RequestResult.ApplicationError -> cause + } +} + private fun WebsocketResponse.toRequestResult(clazz: KClass): RequestResult { return if (status < 200 || status > 299) { RequestResult.NonSuccess(RestStatusCodeError(status, headers, body?.toByteArray())) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountApi.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountApi.kt index 5db3a1f515..2dcd56af2a 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountApi.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountApi.kt @@ -6,11 +6,15 @@ package org.whispersystems.signalservice.api.account import kotlinx.coroutines.runBlocking -import org.signal.core.util.Base64 +import org.signal.core.models.MasterKey import org.signal.core.util.Base64.encodeUrlSafeWithoutPadding +import org.signal.libsignal.net.AuthAccountsService import org.signal.libsignal.net.AuthDevicesService import org.signal.libsignal.net.AuthUsernamesService import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.net.SvrKey +import org.signal.libsignal.net.UsernameNotAvailableException +import org.signal.libsignal.net.UsernameNotSetException import org.signal.libsignal.usernames.BaseUsernameException import org.signal.libsignal.usernames.Username import org.signal.network.NetworkResult @@ -24,12 +28,6 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents import org.whispersystems.signalservice.api.websocket.SignalWebSocket import org.whispersystems.signalservice.internal.push.ConfirmUsernameRequest import org.whispersystems.signalservice.internal.push.ConfirmUsernameResponse -import org.whispersystems.signalservice.internal.push.PhoneNumberDiscoverabilityRequest -import org.whispersystems.signalservice.internal.push.PushServiceSocket -import org.whispersystems.signalservice.internal.push.ReserveUsernameRequest -import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse -import org.whispersystems.signalservice.internal.push.SetUsernameLinkRequestBody -import org.whispersystems.signalservice.internal.push.SetUsernameLinkResponseBody import org.whispersystems.signalservice.internal.push.VerifyAccountResponse import org.whispersystems.signalservice.internal.push.WhoAmIResponse import java.security.SecureRandom @@ -99,31 +97,51 @@ class AccountApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSock /** * Set whether this account is discoverable by phone number. Unlike [setAccountAttributes], this * dedicated endpoint can be called from a linked device. + */ + fun setPhoneNumberDiscoverability(discoverable: Boolean): RequestResult { + return runBlocking { + authWebSocket.runCatchingWithChatConnection { connection -> + AuthAccountsService(connection).setDiscoverableByPhoneNumber(discoverable) + } + } + } + + /** + * Enables the registration lock, deriving the lock secret from [masterKey]. While enabled, re-registering this + * account's phone number requires proving knowledge of the secret. Only the primary device may do this. + */ + fun enableRegistrationLock(masterKey: MasterKey): RequestResult { + return runBlocking { + authWebSocket.runCatchingWithChatConnection { connection -> + AuthAccountsService(connection).setRegistrationLock(SvrKey(masterKey.serialize())) + } + } + } + + /** + * Removes any registration lock from the account. Also succeeds if no lock was set. Only the primary device may + * do this. + */ + fun disableRegistrationLock(): RequestResult { + return runBlocking { + authWebSocket.runCatchingWithChatConnection { connection -> + AuthAccountsService(connection).clearRegistrationLock() + } + } + } + + /** + * Sets the registration recovery password, derived from [masterKey], letting this account re-register its phone number without SMS verification. + * Any of the account's devices may do this. * - * PUT /v2/accounts/phone_number_discoverability - * - 204: Success + * Note that we normally set the recovery password as part of [setAccountAttributes] instead. */ - fun setPhoneNumberDiscoverability(discoverable: Boolean): RequestResult { - val request = WebSocketRequestMessage.put("/v2/accounts/phone_number_discoverability", PhoneNumberDiscoverabilityRequest(discoverable)) - return authWebSocket.fromWebSocketRequest(request, Unit::class) - } - - /** - * PUT /v1/accounts/registration_lock - * - 204: Success - */ - fun enableRegistrationLock(registrationLock: String): NetworkResult { - val request = WebSocketRequestMessage.put("/v1/accounts/registration_lock", PushServiceSocket.RegistrationLockV2(registrationLock)) - return NetworkResult.fromWebSocketRequest(authWebSocket, request) - } - - /** - * DELETE /v1/accounts/registration_lock - * - 204: Success - */ - fun disableRegistrationLock(): NetworkResult { - val request = WebSocketRequestMessage.delete("/v1/accounts/registration_lock") - return NetworkResult.fromWebSocketRequest(authWebSocket, request) + fun setRegistrationRecoveryPassword(masterKey: MasterKey): RequestResult { + return runBlocking { + authWebSocket.runCatchingWithChatConnection { connection -> + AuthAccountsService(connection).setRegistrationRecoveryPassword(SvrKey(masterKey.serialize())) + } + } } /** @@ -168,18 +186,16 @@ class AccountApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSock * Reserve a username for the account. This replaces an existing reservation if one exists. The username is guaranteed to be available for 5 minutes and can * be confirmed with confirmUsername. * - * PUT /v1/accounts/username_hash/reserve - * - 200: Success - * - 409: Username taken - * - 422: Username malformed - * - 429: Rate limited - * - * @param usernameHashes A list of hashed usernames encoded as web-safe base64 strings without padding. The list will have a max length of 20, and each hash will be 32 bytes. - * @return The reserved username. It is available for confirmation for 5 minutes. + * @param usernameHashes A prioritized list of 32-byte username hashes. Must contain between 1 and 20 entries. + * @return The hash of the reserved username. It is available for confirmation for 5 minutes. A [UsernameNotAvailableException] means none of the provided + * hashes were available. */ - fun reserveUsername(usernameHashes: List): NetworkResult { - val request = WebSocketRequestMessage.put("/v1/accounts/username_hash/reserve", ReserveUsernameRequest(usernameHashes)) - return NetworkResult.fromWebSocketRequest(authWebSocket, request, ReserveUsernameResponse::class) + fun reserveUsername(usernameHashes: List): RequestResult { + return runBlocking { + authWebSocket.runCatchingWithChatConnection { connection -> + AuthUsernamesService(connection).reserveUsernameHash(usernameHashes) + } + } } /** @@ -230,36 +246,39 @@ class AccountApi(private val authWebSocket: SignalWebSocket.AuthenticatedWebSock } /** - * Creates a new username link for the given [usernameLink]. - * - * PUT /v1/accounts/username_link - * - 200: Success - * - 409: Username is not set - * - 422: Invalid [SetUsernameLinkRequestBody] format - * - 429: Rate limited + * Creates a new username link for the given [usernameLink]. A [UsernameNotSetException] means the account has no username set. */ - fun createUsernameLink(usernameLink: Username.UsernameLink): NetworkResult { - return modifyUsernameLink(usernameLink, false) + fun createUsernameLink(usernameLink: Username.UsernameLink): RequestResult { + return modifyUsernameLink(usernameLink, keepLinkHandle = false) } /** - * Update account username link for the given [usernameLink]. - * - * PUT /v1/accounts/username_link - * - 200: Success - * - 409: Username is not set - * - 422: Invalid [SetUsernameLinkRequestBody] format - * - 429: Rate limited + * Updates the account's username link to the given [usernameLink], keeping the existing link handle. + * A [UsernameNotSetException] means the account has no username set. */ - fun updateUsernameLink(usernameLink: Username.UsernameLink): NetworkResult { - return modifyUsernameLink(usernameLink, true) + fun updateUsernameLink(usernameLink: Username.UsernameLink): RequestResult { + return modifyUsernameLink(usernameLink, keepLinkHandle = true) } - private fun modifyUsernameLink(usernameLink: Username.UsernameLink, keepLinkHandle: Boolean): NetworkResult { - val encryptedUsername = Base64.encodeUrlSafeWithPadding(usernameLink.encryptedUsername) - val request = WebSocketRequestMessage.put("/v1/accounts/username_link", SetUsernameLinkRequestBody(encryptedUsername, keepLinkHandle)) + /** + * Clears any username link on the account, deactivating the link handle but leaving the username hash in place. This also succeeds if the account has no + * username link, so a caller retrying a deletion sees the same result as the original call. + * + * Note that our own delete flow uses [deleteUsernameHash], which clears the link as well. + */ + fun deleteUsernameLink(): RequestResult { + return runBlocking { + authWebSocket.runCatchingWithChatConnection { connection -> + AuthUsernamesService(connection).deleteUsernameLink() + } + } + } - return NetworkResult.fromWebSocketRequest(authWebSocket, request, SetUsernameLinkResponseBody::class) - .map { UsernameLinkComponents(usernameLink.entropy, it.usernameLinkHandle) } + private fun modifyUsernameLink(usernameLink: Username.UsernameLink, keepLinkHandle: Boolean): RequestResult { + return runBlocking { + authWebSocket.runCatchingWithChatConnection { connection -> + AuthUsernamesService(connection).setUsernameLink(usernameLink.encryptedUsername, keepLinkHandle) + }.map { UsernameLinkComponents(usernameLink.entropy, it) } + } } } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetBackupInfoResponse.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetBackupInfoResponse.kt deleted file mode 100644 index 7774fd1d12..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetBackupInfoResponse.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.signalservice.api.archive - -import com.fasterxml.jackson.annotation.JsonProperty - -/** - * Represents the response when fetching the archive backup info. - */ -data class ArchiveGetBackupInfoResponse( - @JsonProperty - val cdn: Int?, - @JsonProperty - val backupDir: String?, - @JsonProperty - val mediaDir: String?, - @JsonProperty - val backupName: String?, - @JsonProperty - val usedSpace: Long? -) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaRequest.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaRequest.kt deleted file mode 100644 index 1232e61983..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaRequest.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 -) { - class SourceAttachment( - @JsonProperty val cdn: Int, - @JsonProperty val key: String - ) -} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaResponse.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaResponse.kt deleted file mode 100644 index 3c2281deb2..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaResponse.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 -) { - enum class StatusCodes(val code: Int) { - BadArguments(400), - InvalidPresentationOrSignature(401), - InsufficientPermissions(403), - NoMediaSpaceRemaining(413), - RateLimited(429), - Unknown(-1); - - companion object { - fun from(code: Int): StatusCodes { - return entries.firstOrNull { it.code == code } ?: Unknown - } - } - } -} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveSetPublicKeyRequest.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveSetPublicKeyRequest.kt deleted file mode 100644 index 89277d2727..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveSetPublicKeyRequest.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.signalservice.api.archive - -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.databind.JsonSerializer -import com.fasterxml.jackson.databind.SerializerProvider -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import org.signal.core.util.Base64 -import org.signal.libsignal.protocol.ecc.ECPublicKey - -/** - * Represents the request body when setting the archive public key. - */ -class ArchiveSetPublicKeyRequest( - @JsonProperty - @JsonSerialize(using = PublicKeySerializer::class) - val backupIdPublicKey: ECPublicKey -) { - class PublicKeySerializer : JsonSerializer() { - override fun serialize(value: ECPublicKey, gen: JsonGenerator, serializers: SerializerProvider) { - gen.writeString(Base64.encodeWithPadding(value.serialize())) - } - } -} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaRequest.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaRequest.kt deleted file mode 100644 index 3f4822b547..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * 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/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaResponse.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaResponse.kt deleted file mode 100644 index 2fb002e4cf..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/BatchArchiveMediaResponse.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/DeleteArchivedMediaRequest.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/DeleteArchivedMediaRequest.kt deleted file mode 100644 index a46c9a8ab8..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/DeleteArchivedMediaRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 -) { - data class ArchivedMediaObject( - @JsonProperty val cdn: Int, - @JsonProperty val mediaId: String - ) -} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetDeviceNameRequest.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetDeviceNameRequest.kt deleted file mode 100644 index 9723284d76..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/SetDeviceNameRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.signalservice.api.link - -import com.fasterxml.jackson.annotation.JsonProperty - -/** - * Request body for setting the name of a linked device. - */ -data class SetDeviceNameRequest( - @JsonProperty val deviceName: String -) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/profiles/ProfileApi.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/profiles/ProfileApi.kt index a9cd82885d..6ef219d56e 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/profiles/ProfileApi.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/profiles/ProfileApi.kt @@ -8,6 +8,8 @@ package org.whispersystems.signalservice.api.profiles import org.signal.core.models.ServiceId import org.signal.core.util.Hex import org.signal.core.util.logging.Log +import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.net.UnauthProfilesService import org.signal.libsignal.zkgroup.VerificationFailedException import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential @@ -184,6 +186,17 @@ class ProfileApi( } } + /** + * Whether an account with the given ACI or PNI exists. This is an unauthenticated request. + * + * Note that we normally learn this from a 404 on a profile fetch instead. + */ + suspend fun accountExists(serviceId: ServiceId): RequestResult { + return unauthWebSocket.runCatchingWithChatConnection { connection -> + UnauthProfilesService(connection).accountExists(serviceId.libSignalServiceId) + } + } + private class ProfileAndCredentialResponseConverter( private val clientZkProfileOperations: ClientZkProfileOperations, private val requestContext: ProfileKeyCredentialRequestContext diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/SignalWebSocket.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/SignalWebSocket.kt index 37e0d7df05..d6d5e98a56 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/SignalWebSocket.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/websocket/SignalWebSocket.kt @@ -368,6 +368,14 @@ sealed class SignalWebSocket( suspend fun runCatchingWithChatConnection( callback: (UnauthenticatedChatConnection) -> CompletableFuture> ): RequestResult = runCatchingWithChatConnectionInternal { callback(it as UnauthenticatedChatConnection) } + + /** + * Companion to [runCatchingWithChatConnection] for libsignal's streaming endpoints, which hand back a [kotlinx.coroutines.flow.Flow] rather than a future. + * The stream is started on the chat connection here; collecting it (and classifying whatever it throws) is up to the caller. + */ + suspend fun withChatConnection(callback: (UnauthenticatedChatConnection) -> T): T { + return getWebSocket().runWithChatConnection { callback(it as UnauthenticatedChatConnection) } + } } /** diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PhoneNumberDiscoverabilityRequest.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PhoneNumberDiscoverabilityRequest.kt deleted file mode 100644 index 772db79bfb..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PhoneNumberDiscoverabilityRequest.kt +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.signalservice.internal.push - -import com.fasterxml.jackson.annotation.JsonProperty - -data class PhoneNumberDiscoverabilityRequest( - @JsonProperty val discoverableByPhoneNumber: Boolean -) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 5fa22b439b..7169d91503 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -1610,17 +1610,6 @@ public class PushServiceSocket { public enum VerificationCodeTransport { SMS, VOICE } - public static class RegistrationLockV2 { - @JsonProperty - private String registrationLock; - - public RegistrationLockV2() {} - - public RegistrationLockV2(String registrationLock) { - this.registrationLock = registrationLock; - } - } - public static class RegistrationLockFailure { @JsonProperty public int length; diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java deleted file mode 100644 index e0f3d96620..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.whispersystems.signalservice.internal.push; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -public class ReserveUsernameRequest { - @JsonProperty - private List usernameHashes; - - public ReserveUsernameRequest(List usernameHashes) { - this.usernameHashes = usernameHashes; - } - - List getUsernameHashes() { - return usernameHashes; - } -} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java deleted file mode 100644 index 011c0b7268..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.whispersystems.signalservice.internal.push; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class ReserveUsernameResponse { - @JsonProperty - private String usernameHash; - - ReserveUsernameResponse() {} - - /** - * Visible for testing. - */ - public ReserveUsernameResponse(String usernameHash) { - this.usernameHash = usernameHash; - } - - public String getUsernameHash() { - return usernameHash; - } -} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt deleted file mode 100644 index 67778d2383..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.whispersystems.signalservice.internal.push - -import com.fasterxml.jackson.annotation.JsonProperty - -/** Request body for setting a username link on the service. */ -data class SetUsernameLinkRequestBody(@JsonProperty val usernameLinkEncryptedValue: String, @JsonProperty val keepLinkHandle: Boolean) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkResponseBody.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkResponseBody.kt deleted file mode 100644 index 9a28ae35c2..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkResponseBody.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.whispersystems.signalservice.internal.push - -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import org.signal.network.util.JsonUtil.UuidDeserializer -import java.util.UUID - -/** Response body for setting a username link on the service. */ -data class SetUsernameLinkResponseBody( - @JsonProperty - @JsonDeserialize(using = UuidDeserializer::class) - val usernameLinkHandle: UUID -) diff --git a/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt b/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt index 5cc71fc5b0..b72b7172e9 100644 --- a/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt +++ b/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt @@ -5,38 +5,48 @@ package org.signal.network.api +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking import org.signal.core.models.ServiceId.ACI -import org.signal.core.models.backup.BackupKey import org.signal.core.models.backup.MediaRootBackupKey import org.signal.core.models.backup.MessageBackupKey import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.logging.Log +import org.signal.libsignal.internal.CompletableFuture +import org.signal.libsignal.net.BackupAuth +import org.signal.libsignal.net.BadRequestError +import org.signal.libsignal.net.CopyBackupMediaItem +import org.signal.libsignal.net.CopyBackupMediaOutcome +import org.signal.libsignal.net.DeleteBackupMediaItem +import org.signal.libsignal.net.GetUploadFormError +import org.signal.libsignal.net.MediaBackupInfo +import org.signal.libsignal.net.MessageBackupInfo +import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.net.RequestUnauthorizedException +import org.signal.libsignal.net.UnauthBackupsService +import org.signal.libsignal.net.UnauthenticatedChatConnection +import org.signal.libsignal.net.UploadForm +import org.signal.libsignal.net.UploadTooLargeException +import org.signal.libsignal.net.toRequestResult import org.signal.libsignal.protocol.ecc.ECPrivateKey -import org.signal.libsignal.protocol.ecc.ECPublicKey import org.signal.libsignal.zkgroup.GenericServerPublicParams import org.signal.libsignal.zkgroup.backups.BackupAuthCredential import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse import org.signal.network.NetworkResult +import org.signal.network.exceptions.NonSuccessfulResponseCodeException import org.signal.network.websocket.WebSocketRequestMessage -import org.signal.network.websocket.delete import org.signal.network.websocket.get -import org.signal.network.websocket.post import org.signal.network.websocket.put 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.ArchiveGetMediaItemsResponse.StoredMediaObject import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse -import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest -import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess 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.archive.GetArchiveCdnCredentialsResponse import org.whispersystems.signalservice.api.fromWebSocketRequest import org.whispersystems.signalservice.api.messages.SignalServiceAttachment @@ -48,7 +58,6 @@ import java.io.InputStream import java.time.Instant import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds /** * Class to interact with various archive-related endpoints. @@ -91,23 +100,16 @@ class ArchiveApi( } /** - * Gets credentials needed to read from the CDN. Make sure you use the right [backupKey] depending on whether you're doing a message or media operation. + * Gets credentials needed to read from the CDN. Make sure you use the right [archiveServiceAccess] depending on whether you're doing a message or media + * operation. * - * GET /v1/archives/auth/read - * - * - 200: Success - * - 400: Bad arguments, or made on an authenticated channel - * - 401: Bad presentation, invalid public key signature, no matching backupId on teh server, or the credential was of the wrong type (messages/media) - * - 403: Forbidden + * - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media) * - 429: Rate-limited */ fun getCdnReadCredentials(cdnNumber: Int, aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.get("/v1/archives/auth/read?cdn=$cdnNumber", headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request, GetArchiveCdnCredentialsResponse::class) - } + return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> + UnauthBackupsService(connection).getCdnCredentials(auth, cdnNumber) + }.toNetworkResult().map { GetArchiveCdnCredentialsResponse(it.headers) } } /** @@ -139,110 +141,67 @@ class ArchiveApi( * unauthorized users from changing your backup data. You only need to do it once, but repeated * calls are safe. * - * PUT /v1/archives/keys - * - * - 204: Success - * - 400: Bad arguments, or request was made on an authenticated channel - * - 401: Bad presentation, invalid public key signature, no matching backupId on teh server, or the credential was of the wrong type (messages/media) - * - 403: Forbidden + * - 401: The credential in particular is invalid, since the key is being updated * - 429: Rate-limited */ fun setPublicKey(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .then { presentation -> - val headers = presentation.toArchiveCredentialPresentation().toHeaders() - val publicKey = presentation.publicKey - - val request = WebSocketRequestMessage.put("/v1/archives/keys", ArchiveSetPublicKeyRequest(publicKey), headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request) - } + return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).setPublicKey(auth) }.toNetworkResult() } /** * Fetches an upload form you can use to upload your main message backup file to cloud storage. * - * GET /v1/archives/upload/form - * - 200: Success - * - 400: Bad args, or made on an authenticated channel - * - 403: Insufficient permissions + * - 401: Authorization failed * - 413: The backup is too large * - 429: Rate-limited */ fun getMessageBackupUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess, backupFileSize: Long): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.get("/v1/archives/upload/form?uploadLength=$backupFileSize", headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AttachmentUploadForm::class) - } + return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> + UnauthBackupsService(connection).getUploadForm(auth, backupFileSize) + }.toUploadFormNetworkResult().map { it.toAttachmentUploadForm() } } /** - * Fetches metadata about your current backup. This will be different for different key/credential pairs. For example, message credentials will always - * return 0 for used space since that is stored under the media key/credential. + * Fetches metadata about the currently-stored message backup. * - * Will return a [NetworkResult.StatusCodeError] with status code 404 if you haven't uploaded a backup yet. - * - * GET /v1/archives - * - 200: Success - * - 400: Bad arguments. The request may have been made on an authenticated channel. - * - 401: The provided backup auth credential presentation could not be verified or the public key signature was invalid or there is no backup associated with - * the backup-id in the presentation or the credential was of the wrong type (messages/media) - * - 403: Forbidden - * - 404: No backup - * - 429: Rate limited + * - 401: Authorization failed. Note that the server does not distinguish an invalid credential from a backup-id that was never provisioned, so callers using + * this to check whether a backup exists should treat a 401 as "backups not set up" rather than a fatal error. + * - 429: Rate-limited */ - fun getBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.get("/v1/archives", headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveGetBackupInfoResponse::class) - } + fun getMessageBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess): NetworkResult { + return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).getMessageBackupInfo(auth) }.toNetworkResult() } /** - * Indicate that this backup is still active. Clients must periodically upload new backups or perform a refresh via a POST request. If a backup is not - * refreshed, after 30 days it may be deleted. + * Fetches metadata about the currently-stored media backup, including how much space it uses. * - * POST /v1/archives + * - 401: Authorization failed. Note that the server does not distinguish an invalid credential from a backup-id that was never provisioned, so callers using + * this to check whether a backup exists should treat a 401 as "backups not set up" rather than a fatal error. + * - 429: Rate-limited + */ + fun getMediaBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess): NetworkResult { + return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).getMediaBackupInfo(auth) }.toNetworkResult() + } + + /** + * Indicate that this backup is still active. Clients must periodically upload new backups or perform a refresh. If a backup is not refreshed, after 30 days + * it may be deleted. * - * - 204: The backup was successfully refreshed. - * - 400: Bad arguments. The request may have been made on an authenticated channel. - * - 401: The provided backup auth credential presentation could not be verified or The public key signature was invalid or There is no backup associated with - * the backup-id in the presentation or The credential was of the wrong type (messages/media) - * - 403: Forbidden. The request had insufficient permissions to perform the requested action. - * - 429: Rate limited. + * - 401: Authorization failed + * - 429: Rate-limited */ fun refreshBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.post(path = "/v1/archives", body = null, headers = headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request) - } + return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).refresh(auth) }.toNetworkResult() } /** * Delete all backup metadata, objects, and stored public key. To use backups again, a public key must be resupplied. * - * DELETE /v1/archives - * - * - 204: The backup has been successfully deleted - * - 400: Bad arguments. The request may have been made on an authenticated channel. - * - 401: The provided backup auth credential presentation could not be verified or The public key signature was invalid or There is no backup associated with - * the backup-id in the presentation or The credential was of the wrong type (messages/media) - * - 403: Forbidden. The request had insufficient permissions to perform the requested action. - * - 429: Rate limited. - * + * - 401: Authorization failed + * - 429: Rate-limited */ fun deleteBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.delete("/v1/archives", headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request) - } + return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).deleteAll(auth) }.toNetworkResult() } /** @@ -287,23 +246,16 @@ class ArchiveApi( * This is basically the same as [org.signal.network.api.AttachmentApi.getAttachmentV4UploadForm], but with a relaxed rate limit * so we can request them more often (which is required for backfilling). * - * After uploading, the media still needs to be copied via [copyAttachmentToArchive]. + * After uploading, the media still needs to be copied via [copyMediaToArchive]. * - * GET /v1/archives/media/upload/form - * - * - 200: Success - * - 400: Bad request, or made on authenticated channel - * - 403: Forbidden + * - 401: Authorization failed * - 413: The media is too large * - 429: Rate-limited */ fun getMediaUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess, uploadLength: Long): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.get("/v1/archives/media/upload/form?uploadLength=$uploadLength", headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AttachmentUploadForm::class) - } + return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> + UnauthBackupsService(connection).getMediaUploadForm(auth, uploadLength) + }.toUploadFormNetworkResult().map { it.toAttachmentUploadForm() } } /** @@ -339,8 +291,7 @@ class ArchiveApi( * @param cursor A token that can be read from your previous response, telling the server where to start the next page. */ fun getArchiveMediaItemsPage(aci: ACI, archiveServiceAccess: ArchiveServiceAccess, limit: Int, cursor: String?): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } + return getCredentialPresentationHeaders(aci, archiveServiceAccess) .then { headers -> val request = WebSocketRequestMessage.get("/v1/archives/media?limit=$limit${if (cursor.isNotNullOrBlank()) "&cursor=$cursor" else ""}", headers) NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveGetMediaItemsResponse::class) @@ -350,84 +301,49 @@ class ArchiveApi( /** * Copy and re-encrypt media from the attachments cdn into the backup cdn. * - * PUT /v1/archives/media + * The copy operation is not atomic: each item gets its own [CopyBackupMediaOutcome], and there is no need to retry items that produced one. If the stream + * terminates early, the returned list only contains the outcomes received so far, so a partial success is reported as a failure carrying no outcomes. * - * - 200: Success - * - 400: Bad arguments, or made on an authenticated channel - * - 401: Invalid presentation or signature - * - 403: Insufficient permissions - * - 410: The source object was not found - * - 413: No media space remaining + * - 401: Authorization failed. Because large batches span multiple server requests, this can happen partway through. * - 429: Rate-limited */ - fun copyAttachmentToArchive( + fun copyMediaToArchive( aci: ACI, archiveServiceAccess: ArchiveServiceAccess, - item: ArchiveMediaRequest - ): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.put("/v1/archives/media", item, headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveMediaResponse::class) - } - } - - /** - * Copy and re-encrypt media from the attachments cdn into the backup cdn. - */ - fun copyAttachmentToArchive( - aci: ACI, - archiveServiceAccess: ArchiveServiceAccess, - items: List - ): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.put("/v1/archives/media/batch", BatchArchiveMediaRequest(items = items), headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request, BatchArchiveMediaResponse::class) - } + items: List + ): NetworkResult> { + return collectBackupMediaStream(aci, archiveServiceAccess) { connection, auth -> + UnauthBackupsService(connection).copyMedia(auth, items) + }.toNetworkResult() } /** * Delete media from the backup cdn. * - * POST /v1/archives/media/delete + * Like [copyMediaToArchive], the operation is not atomic and a stream that terminates early reports failure rather than a partial result. * - * - 400: Bad args or made on an authenticated channel - * - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media) - * - 403: Forbidden + * - 401: Authorization failed * - 429: Rate-limited */ fun deleteArchivedMedia( aci: ACI, archiveServiceAccess: ArchiveServiceAccess, - mediaToDelete: List - ): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.post("/v1/archives/media/delete", DeleteArchivedMediaRequest(mediaToDelete = mediaToDelete), headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request, timeout = 30.seconds) - } + mediaToDelete: List + ): NetworkResult> { + return collectBackupMediaStream(aci, archiveServiceAccess) { connection, auth -> + UnauthBackupsService(connection).deleteMedia(auth, mediaToDelete) + }.toNetworkResult() } /** - * Retrieves auth credentials that can be used to perform SVRB operations. + * Retrieves auth credentials that can be used to perform SVR-B operations. * - * GET /v1/archives/auth/svrb - * - 200: Success - * - 400: Bad arguments, or made on an authenticated channel - * - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media) - * - 403: Forbidden + * - 401: Authorization failed */ fun getSvrBAuthorization(aci: ACI, archiveServiceAccess: ArchiveServiceAccess): NetworkResult { - return getCredentialPresentation(aci, archiveServiceAccess) - .map { it.toArchiveCredentialPresentation().toHeaders() } - .then { headers -> - val request = WebSocketRequestMessage.get("/v1/archives/auth/svrb", headers) - NetworkResult.fromWebSocketRequest(unauthWebSocket, request, AuthCredentials::class) - } + return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> + UnauthBackupsService(connection).getSvrBCredentials(auth) + }.toNetworkResult().map { (username, password) -> AuthCredentials.create(username, password) } } /** @@ -442,10 +358,121 @@ class ArchiveApi( return NetworkResult.fromWebSocketRequest(authWebSocket, request, ArchiveKeyRotationLimitResponse::class) } - private fun getCredentialPresentation(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult { + /** + * Issues an anonymous backup request over the unauthenticated chat connection, deriving the [BackupAuth] from [archiveServiceAccess]. Failing to derive it is + * a local programming error, so it surfaces as [RequestResult.ApplicationError]. + */ + private fun runWithBackupAuth( + aci: ACI, + archiveServiceAccess: ArchiveServiceAccess<*>, + block: (UnauthenticatedChatConnection, BackupAuth) -> CompletableFuture> + ): RequestResult { + val auth = try { + getBackupAuth(aci, archiveServiceAccess) + } catch (e: Throwable) { + return RequestResult.ApplicationError(e) + } + + return runBlocking { + unauthWebSocket.runCatchingWithChatConnection { connection -> block(connection, auth) } + } + } + + /** + * Drains a per-item backup media stream into a list. A stream that terminates early throws, which we classify the same way the non-streaming endpoints do. + */ + private fun collectBackupMediaStream( + aci: ACI, + archiveServiceAccess: ArchiveServiceAccess<*>, + block: (UnauthenticatedChatConnection, BackupAuth) -> Flow + ): RequestResult, RequestUnauthorizedException> { + val auth = try { + getBackupAuth(aci, archiveServiceAccess) + } catch (e: Throwable) { + return RequestResult.ApplicationError(e) + } + + return runBlocking { + try { + val stream = unauthWebSocket.withChatConnection { connection -> block(connection, auth) } + RequestResult.Success(stream.toList()) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + e.toRequestResult() + } + } + } + + /** + * Builds the anonymous-credential headers for the archive endpoints that still go out as hand-rolled websocket requests. + */ + private fun getCredentialPresentationHeaders(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult> { return NetworkResult.fromLocal { - val zkCredential = getZkCredential(aci, archiveServiceAccess) - CredentialPresentationData.from(archiveServiceAccess.backupKey, aci, zkCredential, backupServerPublicParams) + val privateKey: ECPrivateKey = archiveServiceAccess.backupKey.deriveAnonymousCredentialPrivateKey(aci) + val presentation: ByteArray = getZkCredential(aci, archiveServiceAccess).present(backupServerPublicParams).serialize() + + ArchiveCredentialPresentation( + presentation = presentation, + signedPresentation = privateKey.calculateSignature(presentation) + ).toHeaders() + } + } + + private fun getBackupAuth(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): BackupAuth { + return BackupAuth( + credential = getZkCredential(aci, archiveServiceAccess), + serverKeys = backupServerPublicParams, + signingKey = archiveServiceAccess.backupKey.deriveAnonymousCredentialPrivateKey(aci) + ) + } + + private fun UploadForm.toAttachmentUploadForm(): AttachmentUploadForm { + return AttachmentUploadForm( + cdn = cdn, + key = key, + headers = headers, + signedUploadLocation = signedUploadUrl.toString() + ) + } + + /** + * Converts a libsignal result into a [NetworkResult] for this class's callers, which still chain [NetworkResult]. + * + * Note that libsignal reports both HTTP 401 and 403 as [RequestUnauthorizedException] ("incorrect or insufficient" authorization), so a caller that used to + * distinguish "bad credential" from "insufficient permissions" only ever sees the 401. + */ + private fun RequestResult.toNetworkResult(): NetworkResult { + return when (this) { + is RequestResult.Success -> NetworkResult.Success(result) + is RequestResult.NonSuccess -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(401, "Unauthorized")) + is RequestResult.RetryableNetworkError -> toNetworkResult() + is RequestResult.ApplicationError -> NetworkResult.ApplicationError(cause) + } + } + + /** + * [toNetworkResult] for the upload-form endpoints, which can additionally reject the requested size. + */ + private fun RequestResult.toUploadFormNetworkResult(): NetworkResult { + return when (this) { + is RequestResult.Success -> NetworkResult.Success(result) + is RequestResult.NonSuccess -> when (error) { + is UploadTooLargeException -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(413, "Upload too large")) + is RequestUnauthorizedException -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(401, "Unauthorized")) + } + is RequestResult.RetryableNetworkError -> toNetworkResult() + is RequestResult.ApplicationError -> NetworkResult.ApplicationError(cause) + } + } + + /** + * A rate limit becomes a synthetic 429 carrying a `retry-after` header, since that's where [NetworkResult.StatusCodeError.retryAfter] looks for it. + */ + private fun RequestResult.RetryableNetworkError.toNetworkResult(): NetworkResult { + return when (val retryAfter = retryAfter) { + null -> NetworkResult.NetworkError(networkError) + else -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(429, "Rate limited", null as String?, mapOf("retry-after" to retryAfter.seconds.toString()))) } } @@ -459,29 +486,4 @@ class ArchiveApi( backupServerPublicParams ) } - - private class CredentialPresentationData( - val privateKey: ECPrivateKey, - val presentation: ByteArray, - val signedPresentation: ByteArray - ) { - val publicKey: ECPublicKey = privateKey.getPublicKey() - - companion object { - fun from(backupKey: BackupKey, aci: ACI, credential: BackupAuthCredential, backupServerPublicParams: GenericServerPublicParams): CredentialPresentationData { - val privateKey: ECPrivateKey = backupKey.deriveAnonymousCredentialPrivateKey(aci) - val presentation: ByteArray = credential.present(backupServerPublicParams).serialize() - val signedPresentation: ByteArray = privateKey.calculateSignature(presentation) - - return CredentialPresentationData(privateKey, presentation, signedPresentation) - } - } - - fun toArchiveCredentialPresentation(): ArchiveCredentialPresentation { - return ArchiveCredentialPresentation( - presentation = presentation, - signedPresentation = signedPresentation - ) - } - } } diff --git a/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt b/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt index a4040fff93..fe589aabc0 100644 --- a/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt +++ b/lib/network/src/main/java/org/signal/network/api/LinkDeviceApi.kt @@ -16,6 +16,7 @@ import org.signal.core.util.Base64 import org.signal.core.util.logging.Log import org.signal.core.util.urlEncode import org.signal.libsignal.net.AuthDevicesService +import org.signal.libsignal.net.DeviceIdNotFoundException import org.signal.libsignal.net.RequestResult import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.ecc.ECPublicKey @@ -26,7 +27,6 @@ import org.signal.network.websocket.get import org.signal.network.websocket.put import org.whispersystems.signalservice.api.fromWebSocketRequest import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse -import org.whispersystems.signalservice.api.link.SetDeviceNameRequest import org.whispersystems.signalservice.api.link.SetLinkedDeviceTransferArchiveRequest import org.whispersystems.signalservice.api.link.TransferArchiveError import org.whispersystems.signalservice.api.link.TransferArchiveResponse @@ -215,17 +215,16 @@ class LinkDeviceApi( } /** - * Sets the name for a linked device + * Sets the name for a linked device. * - * PUT /v1/accounts/name?deviceId=[deviceId] + * @param encryptedDeviceName Must be between 1 and 225 bytes long. * - * - 204: Success. - * - 403: Not authorized to change the name of the device with the given ID - * - 404: No device found with the given ID + * A [DeviceIdNotFoundException] means there is no device with the given [deviceId]. */ - fun setDeviceName(encryptedDeviceName: String, deviceId: Int): NetworkResult { - val request = WebSocketRequestMessage.put("/v1/accounts/name?deviceId=$deviceId", SetDeviceNameRequest(encryptedDeviceName)) - return NetworkResult.fromWebSocketRequest(authWebSocket, request) + suspend fun setDeviceName(encryptedDeviceName: ByteArray, deviceId: Int): RequestResult { + return authWebSocket.runCatchingWithChatConnection { connection -> + AuthDevicesService(connection).setDeviceName(deviceId, encryptedDeviceName) + } } /**