Migrate a batch of requests to libsignal-net.

This commit is contained in:
Greyson Parrelli
2026-08-04 20:03:36 -03:00
committed by Alex Hart
parent 91ffba810f
commit 1911bf7df7
27 changed files with 447 additions and 716 deletions
@@ -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<DebugBackupMetadata> {
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<Unit> {
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<ZonedDateTime> {
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<ArchiveMediaResponse> {
fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult<Int> {
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<Unit> {
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<ArchivedMediaObject>): NetworkResult<Unit> {
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<MediaRootBackupKey>, item: CopyBackupMediaItem): NetworkResult<Int> {
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<ByteArray?> {
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,
@@ -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<String, AttachmentId>,
private val attachmentIdToMediaName: Map<AttachmentId, String>
) {
val successfulResponses: Sequence<BatchArchiveMediaResponse.BatchArchiveMediaItemResponse>
get() = response
.responses
.asSequence()
.filter { it.status == 200 }
val sourceNotFoundResponses: Sequence<BatchArchiveMediaResponse.BatchArchiveMediaItemResponse>
get() = response
.responses
.asSequence()
.filter { it.status == 410 }
fun mediaIdToAttachmentId(mediaId: String): AttachmentId {
return mediaIdToAttachmentId[mediaId]!!
}
fun attachmentIdToMediaName(attachmentId: AttachmentId): String {
return attachmentIdToMediaName[attachmentId]!!
}
}
@@ -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;
@@ -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.")
}
}
}
@@ -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)
}
@@ -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<String> = candidates
.map { Base64.encodeUrlSafeWithoutPadding(it.hash) }
val hashes: List<ByteArray> = 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)
@@ -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<Unit, SetRegistrationLockError> = 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<SvrCredentials, GetSvrCredentialsError> = 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}"))
}
}
@@ -89,6 +89,20 @@ fun <T : Any> RequestResult<T, Nothing>.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 <T : Any> WebsocketResponse.toRequestResult(clazz: KClass<T>): RequestResult<T, RestStatusCodeError> {
return if (status < 200 || status > 299) {
RequestResult.NonSuccess(RestStatusCodeError(status, headers, body?.toByteArray()))
@@ -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<Unit, Nothing> {
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<Unit, Nothing> {
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<Unit, Nothing> {
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<Unit, RestStatusCodeError> {
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<Unit> {
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<Unit> {
val request = WebSocketRequestMessage.delete("/v1/accounts/registration_lock")
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
fun setRegistrationRecoveryPassword(masterKey: MasterKey): RequestResult<Unit, Nothing> {
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<String>): NetworkResult<ReserveUsernameResponse> {
val request = WebSocketRequestMessage.put("/v1/accounts/username_hash/reserve", ReserveUsernameRequest(usernameHashes))
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ReserveUsernameResponse::class)
fun reserveUsername(usernameHashes: List<ByteArray>): RequestResult<ByteArray, UsernameNotAvailableException> {
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<UsernameLinkComponents> {
return modifyUsernameLink(usernameLink, false)
fun createUsernameLink(usernameLink: Username.UsernameLink): RequestResult<UsernameLinkComponents, UsernameNotSetException> {
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<UsernameLinkComponents> {
return modifyUsernameLink(usernameLink, true)
fun updateUsernameLink(usernameLink: Username.UsernameLink): RequestResult<UsernameLinkComponents, UsernameNotSetException> {
return modifyUsernameLink(usernameLink, keepLinkHandle = true)
}
private fun modifyUsernameLink(usernameLink: Username.UsernameLink, keepLinkHandle: Boolean): NetworkResult<UsernameLinkComponents> {
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<Unit, Nothing> {
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<UsernameLinkComponents, UsernameNotSetException> {
return runBlocking {
authWebSocket.runCatchingWithChatConnection { connection ->
AuthUsernamesService(connection).setUsernameLink(usernameLink.encryptedUsername, keepLinkHandle)
}.map { UsernameLinkComponents(usernameLink.entropy, it) }
}
}
}
@@ -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?
)
@@ -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
)
}
@@ -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
}
}
}
}
@@ -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<ECPublicKey>() {
override fun serialize(value: ECPublicKey, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(Base64.encodeWithPadding(value.serialize()))
}
}
}
@@ -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<ArchiveMediaRequest>
)
@@ -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<BatchArchiveMediaItemResponse>
) {
class BatchArchiveMediaItemResponse(
@JsonProperty val status: Int?,
@JsonProperty val failureReason: String?,
@JsonProperty val cdn: Int?,
@JsonProperty val mediaId: String
)
}
@@ -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<ArchivedMediaObject>
) {
data class ArchivedMediaObject(
@JsonProperty val cdn: Int,
@JsonProperty val mediaId: String
)
}
@@ -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
)
@@ -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<Boolean, Nothing> {
return unauthWebSocket.runCatchingWithChatConnection { connection ->
UnauthProfilesService(connection).accountExists(serviceId.libSignalServiceId)
}
}
private class ProfileAndCredentialResponseConverter(
private val clientZkProfileOperations: ClientZkProfileOperations,
private val requestContext: ProfileKeyCredentialRequestContext
@@ -368,6 +368,14 @@ sealed class SignalWebSocket(
suspend fun <Result, Error : BadRequestError> runCatchingWithChatConnection(
callback: (UnauthenticatedChatConnection) -> CompletableFuture<RequestResult<Result, Error>>
): RequestResult<Result, Error> = 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 <T> withChatConnection(callback: (UnauthenticatedChatConnection) -> T): T {
return getWebSocket().runWithChatConnection { callback(it as UnauthenticatedChatConnection) }
}
}
/**
@@ -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
)
@@ -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;
@@ -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<String> usernameHashes;
public ReserveUsernameRequest(List<String> usernameHashes) {
this.usernameHashes = usernameHashes;
}
List<String> getUsernameHashes() {
return usernameHashes;
}
}
@@ -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;
}
}
@@ -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)
@@ -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
)
@@ -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<GetArchiveCdnCredentialsResponse> {
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<Unit> {
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<MessageBackupKey>, backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
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<ArchiveGetBackupInfoResponse> {
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<MessageBackupKey>): NetworkResult<MessageBackupInfo> {
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<MediaRootBackupKey>): NetworkResult<MediaBackupInfo> {
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<Unit> {
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<Unit> {
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<MediaRootBackupKey>, uploadLength: Long): NetworkResult<AttachmentUploadForm> {
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<MediaRootBackupKey>, limit: Int, cursor: String?): NetworkResult<ArchiveGetMediaItemsResponse> {
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<MediaRootBackupKey>,
item: ArchiveMediaRequest
): NetworkResult<ArchiveMediaResponse> {
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<MediaRootBackupKey>,
items: List<ArchiveMediaRequest>
): NetworkResult<BatchArchiveMediaResponse> {
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<CopyBackupMediaItem>
): NetworkResult<List<CopyBackupMediaOutcome>> {
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<MediaRootBackupKey>,
mediaToDelete: List<DeleteArchivedMediaRequest.ArchivedMediaObject>
): NetworkResult<Unit> {
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<DeleteBackupMediaItem>
): NetworkResult<List<DeleteBackupMediaItem>> {
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<MessageBackupKey>): NetworkResult<AuthCredentials> {
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<CredentialPresentationData> {
/**
* 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 <T, E : BadRequestError> runWithBackupAuth(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<*>,
block: (UnauthenticatedChatConnection, BackupAuth) -> CompletableFuture<RequestResult<T, E>>
): RequestResult<T, E> {
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 <T> collectBackupMediaStream(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<*>,
block: (UnauthenticatedChatConnection, BackupAuth) -> Flow<T>
): RequestResult<List<T>, 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<RequestUnauthorizedException>()
}
}
}
/**
* 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<Map<String, String>> {
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 <T> RequestResult<T, RequestUnauthorizedException>.toNetworkResult(): NetworkResult<T> {
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 <T> RequestResult<T, GetUploadFormError>.toUploadFormNetworkResult(): NetworkResult<T> {
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 <T> RequestResult.RetryableNetworkError.toNetworkResult(): NetworkResult<T> {
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
)
}
}
}
@@ -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<Unit> {
val request = WebSocketRequestMessage.put("/v1/accounts/name?deviceId=$deviceId", SetDeviceNameRequest(encryptedDeviceName))
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
suspend fun setDeviceName(encryptedDeviceName: ByteArray, deviceId: Int): RequestResult<Unit, DeviceIdNotFoundException> {
return authWebSocket.runCatchingWithChatConnection { connection ->
AuthDevicesService(connection).setDeviceName(deviceId, encryptedDeviceName)
}
}
/**