Handle rate limits when rotating recovery key.

This commit is contained in:
Michelle Tang
2025-11-04 16:59:14 -05:00
parent 800155e5a6
commit b2013e5d75
10 changed files with 142 additions and 11 deletions

View File

@@ -142,6 +142,7 @@ import org.whispersystems.signalservice.api.ApplicationErrorAction
import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.StatusCodeErrorAction import org.whispersystems.signalservice.api.StatusCodeErrorAction
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse 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.ArchiveMediaRequest
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
@@ -2063,6 +2064,10 @@ object BackupRepository {
.then { SignalNetwork.archive.getSvrBAuthorization(SignalStore.account.requireAci(), it.messageBackupAccess) } .then { SignalNetwork.archive.getSvrBAuthorization(SignalStore.account.requireAci(), it.messageBackupAccess) }
} }
fun getKeyRotationLimit(): NetworkResult<ArchiveKeyRotationLimitResponse> {
return SignalNetwork.archive.getKeyRotationLimit()
}
/** /**
* During normal operation, ensures that the backupId has been reserved and that your public key has been set, * During normal operation, ensures that the backupId has been reserved and that your public key has been set,
* while also returning an archive access data. Should be the basis of all backup operations. * while also returning an archive access data. Should be the basis of all backup operations.

View File

@@ -70,7 +70,8 @@ sealed interface MessageBackupsKeyRecordMode {
data class CreateNewKey( data class CreateNewKey(
val onCreateNewKeyClick: () -> Unit, val onCreateNewKeyClick: () -> Unit,
val onTurnOffAndDownloadClick: () -> Unit, val onTurnOffAndDownloadClick: () -> Unit,
val isOptimizedStorageEnabled: Boolean val isOptimizedStorageEnabled: Boolean,
val canRotateKey: Boolean
) : MessageBackupsKeyRecordMode ) : MessageBackupsKeyRecordMode
} }
@@ -263,10 +264,17 @@ private fun CreateNewKeyButton(
mode: MessageBackupsKeyRecordMode.CreateNewKey mode: MessageBackupsKeyRecordMode.CreateNewKey
) { ) {
var displayBottomSheet by remember { mutableStateOf(false) } var displayBottomSheet by remember { mutableStateOf(false) }
var displayDialog by remember { mutableStateOf(false) } var displayDownloadMediaDialog by remember { mutableStateOf(false) }
var displayKeyLimitDialog by remember { mutableStateOf(false) }
TextButton( TextButton(
onClick = { displayBottomSheet = true }, onClick = {
if (!mode.canRotateKey) {
displayKeyLimitDialog = true
} else {
displayBottomSheet = true
}
},
modifier = Modifier modifier = Modifier
.padding(bottom = 24.dp) .padding(bottom = 24.dp)
.horizontalGutters() .horizontalGutters()
@@ -276,10 +284,16 @@ private fun CreateNewKeyButton(
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__create_new_key)) Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__create_new_key))
} }
if (displayDialog) { if (displayKeyLimitDialog) {
KeyLimitExceededDialog(
onClick = { displayKeyLimitDialog = false }
)
}
if (displayDownloadMediaDialog) {
DownloadMediaDialog( DownloadMediaDialog(
onTurnOffAndDownloadClick = mode.onTurnOffAndDownloadClick, onTurnOffAndDownloadClick = mode.onTurnOffAndDownloadClick,
onCancelClick = { displayDialog = false } onCancelClick = { displayDownloadMediaDialog = false }
) )
} }
@@ -291,7 +305,7 @@ private fun CreateNewKeyButton(
CreateNewBackupKeySheetContent( CreateNewBackupKeySheetContent(
onContinueClick = { onContinueClick = {
if (mode.isOptimizedStorageEnabled) { if (mode.isOptimizedStorageEnabled) {
displayDialog = true displayDownloadMediaDialog = true
} else { } else {
mode.onCreateNewKeyClick() mode.onCreateNewKeyClick()
} }
@@ -448,6 +462,19 @@ private fun DownloadMediaDialog(
) )
} }
@Composable
private fun KeyLimitExceededDialog(
onClick: () -> Unit = {}
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_exceeded_title),
body = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_exceeded_body),
confirm = stringResource(R.string.MessageBackupsKeyRecordScreen__ok),
onConfirm = {},
onDismiss = onClick
)
}
private suspend fun saveKeyToCredentialManager( private suspend fun saveKeyToCredentialManager(
@UiContext activityContext: Context, @UiContext activityContext: Context,
backupKey: String backupKey: String
@@ -470,7 +497,8 @@ private fun MessageBackupsKeyRecordScreenPreview() {
mode = MessageBackupsKeyRecordMode.CreateNewKey( mode = MessageBackupsKeyRecordMode.CreateNewKey(
onCreateNewKeyClick = {}, onCreateNewKeyClick = {},
onTurnOffAndDownloadClick = {}, onTurnOffAndDownloadClick = {},
isOptimizedStorageEnabled = true isOptimizedStorageEnabled = true,
canRotateKey = true
) )
) )
} }
@@ -507,3 +535,11 @@ private fun DownloadMediaDialogPreview() {
DownloadMediaDialog() DownloadMediaDialog()
} }
} }
@DayNightPreviews
@Composable
private fun KeyLimitExceededDialogPreview() {
Previews.Preview {
KeyLimitExceededDialog()
}
}

View File

@@ -75,7 +75,7 @@ class BackupKeyDisplayFragment : ComposeFragment() {
displayWarningDialog = true displayWarningDialog = true
} }
val mode = remember(state.rotationState) { val mode = remember(state.rotationState, state.canRotateKey) {
if (state.rotationState == BackupKeyRotationState.NOT_STARTED) { if (state.rotationState == BackupKeyRotationState.NOT_STARTED) {
MessageBackupsKeyRecordMode.CreateNewKey( MessageBackupsKeyRecordMode.CreateNewKey(
onCreateNewKeyClick = { onCreateNewKeyClick = {
@@ -85,7 +85,8 @@ class BackupKeyDisplayFragment : ComposeFragment() {
viewModel.turnOffOptimizedStorageAndDownloadMedia() viewModel.turnOffOptimizedStorageAndDownloadMedia()
findNavController().popBackStack() findNavController().popBackStack()
}, },
isOptimizedStorageEnabled = state.isOptimizedStorageEnabled isOptimizedStorageEnabled = state.isOptimizedStorageEnabled,
canRotateKey = state.canRotateKey
) )
} else { } else {
MessageBackupsKeyRecordMode.Next( MessageBackupsKeyRecordMode.Next(

View File

@@ -14,14 +14,21 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.StagedBackupKeyRotations import org.thoughtcrime.securesms.backup.v2.StagedBackupKeyRotations
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.AccountEntropyPool import org.whispersystems.signalservice.api.AccountEntropyPool
import org.whispersystems.signalservice.api.NetworkResult
class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler { class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
companion object {
private val TAG = Log.tag(BackupKeyDisplayViewModel::class.java)
}
private val internalUiState = MutableStateFlow(BackupKeyDisplayUiState()) private val internalUiState = MutableStateFlow(BackupKeyDisplayUiState())
val uiState: StateFlow<BackupKeyDisplayUiState> = internalUiState.asStateFlow() val uiState: StateFlow<BackupKeyDisplayUiState> = internalUiState.asStateFlow()
@@ -29,6 +36,10 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler
internalUiState.update { it.copy(keySaveState = newState) } internalUiState.update { it.copy(keySaveState = newState) }
} }
init {
getKeyRotationLimit()
}
fun rotateBackupKey() { fun rotateBackupKey() {
viewModelScope.launch { viewModelScope.launch {
internalUiState.update { it.copy(rotationState = BackupKeyRotationState.GENERATING_KEY) } internalUiState.update { it.copy(rotationState = BackupKeyRotationState.GENERATING_KEY) }
@@ -61,6 +72,21 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler
} }
} }
fun getKeyRotationLimit() {
viewModelScope.launch(SignalDispatchers.IO) {
val result = BackupRepository.getKeyRotationLimit()
if (result is NetworkResult.Success) {
internalUiState.update {
it.copy(
canRotateKey = result.result.hasPermitsRemaining ?: true
)
}
} else {
Log.w(TAG, "Error while getting rotation limit: $result. Default to allowing key rotations.")
}
}
}
fun turnOffOptimizedStorageAndDownloadMedia() { fun turnOffOptimizedStorageAndDownloadMedia() {
SignalStore.backup.optimizeStorage = false SignalStore.backup.optimizeStorage = false
// TODO - flag to notify when complete. // TODO - flag to notify when complete.
@@ -73,7 +99,8 @@ data class BackupKeyDisplayUiState(
val keySaveState: BackupKeySaveState? = null, val keySaveState: BackupKeySaveState? = null,
val isOptimizedStorageEnabled: Boolean = SignalStore.backup.optimizeStorage, val isOptimizedStorageEnabled: Boolean = SignalStore.backup.optimizeStorage,
val rotationState: BackupKeyRotationState = BackupKeyRotationState.NOT_STARTED, val rotationState: BackupKeyRotationState = BackupKeyRotationState.NOT_STARTED,
val stagedKeyRotations: StagedBackupKeyRotations? = null val stagedKeyRotations: StagedBackupKeyRotations? = null,
val canRotateKey: Boolean = true
) )
enum class BackupKeyRotationState { enum class BackupKeyRotationState {

View File

@@ -324,6 +324,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
setFragmentResultListener(BackupKeyDisplayFragment.AEP_ROTATION_KEY) { _, bundle -> setFragmentResultListener(BackupKeyDisplayFragment.AEP_ROTATION_KEY) { _, bundle ->
val didRotate = bundle.getBoolean(BackupKeyDisplayFragment.AEP_ROTATION_KEY, false) val didRotate = bundle.getBoolean(BackupKeyDisplayFragment.AEP_ROTATION_KEY, false)
if (didRotate) { if (didRotate) {
viewModel.getKeyRotationLimit()
viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.AEP_KEY_ROTATED) viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.AEP_KEY_ROTATED)
} }
} }
@@ -656,6 +657,16 @@ private fun RemoteBackupsSettingsContent(
onDeny = contentCallbacks::onFreeTierBackupSizeLearnMore onDeny = contentCallbacks::onFreeTierBackupSizeLearnMore
) )
} }
RemoteBackupsSettingsState.Dialog.KEY_ROTATION_LIMIT_REACHED -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_reached_title),
body = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_reached_body),
confirm = stringResource(R.string.MessageBackupsKeyRecordScreen__ok),
onConfirm = {},
onDismiss = contentCallbacks::onDialogDismissed
)
}
} }
val snackbarMessageId = remember(state.snackbar) { val snackbarMessageId = remember(state.snackbar) {

View File

@@ -53,7 +53,8 @@ data class RemoteBackupsSettingsState(
SKIP_MEDIA_RESTORE_PROTECTION, SKIP_MEDIA_RESTORE_PROTECTION,
CANCEL_MEDIA_RESTORE_PROTECTION, CANCEL_MEDIA_RESTORE_PROTECTION,
RESTORE_OVER_CELLULAR_PROTECTION, RESTORE_OVER_CELLULAR_PROTECTION,
FREE_TIER_MEDIA_EXPLAINER FREE_TIER_MEDIA_EXPLAINER,
KEY_ROTATION_LIMIT_REACHED
} }
enum class Snackbar { enum class Snackbar {

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.signal.core.util.bytes import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.mebiBytes import org.signal.core.util.mebiBytes
import org.signal.core.util.throttleLatest import org.signal.core.util.throttleLatest
@@ -232,6 +233,22 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
_state.update { it.copy(snackbar = snackbar) } _state.update { it.copy(snackbar = snackbar) }
} }
fun getKeyRotationLimit() {
viewModelScope.launch(SignalDispatchers.IO) {
val result = BackupRepository.getKeyRotationLimit()
val canRotateKey = if (result is NetworkResult.Success) {
result.result.hasPermitsRemaining!!
} else {
Log.w(TAG, "Error while getting rotation limit: $result. Default to allowing key rotations.")
true
}
if (!canRotateKey) {
requestDialog(RemoteBackupsSettingsState.Dialog.KEY_ROTATION_LIMIT_REACHED)
}
}
}
fun refresh() { fun refresh() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id

View File

@@ -8783,6 +8783,16 @@
<!-- No backup to Restore tonal cta to skip restore --> <!-- No backup to Restore tonal cta to skip restore -->
<string name="NoBackupToRestore_skip_restore">Skip restore</string> <string name="NoBackupToRestore_skip_restore">Skip restore</string>
<!-- Dialog title when the recovery key change limit is reached -->
<string name="MessageBackupsKeyRecordScreen__limit_reached_title">Recovery key limit reached</string>
<!-- Dialog body when the recovery key change limit is reached -->
<string name="MessageBackupsKeyRecordScreen__limit_reached_body">You successfully created a new recovery key, but to prevent abuse the number of new keys you can create is limited. You will not be able to create another key for 7 days. Store your recovery key somewhere safe and secure.</string>
<!-- Dialog title when the recovery key change limit is exhausted and you cannot create a new key -->
<string name="MessageBackupsKeyRecordScreen__limit_exceeded_title">Cant create new key</string>
<!-- Dialog body when the recovery key change limit is exhausted and you cannot create a new key. Placeholder is number of days remaining until they can create a new key. -->
<string name="MessageBackupsKeyRecordScreen__limit_exceeded_body">Youve exhausted the number of new keys you can create. You will be able to create a new recovery key in 7 days. If you believe someone may be able to access your backup, you can choose to “Turn off and delete backup.”</string>
<!-- Button to acknowledge and dismiss dialog -->
<string name="MessageBackupsKeyRecordScreen__ok">OK</string>
<!-- Accessibility label for close search button in MainToolbar --> <!-- Accessibility label for close search button in MainToolbar -->
<string name="MainToolbar__close_search_content_description">Close search</string> <string name="MainToolbar__close_search_content_description">Close search</string>
<!-- Accessibility label for clear search query in MainToolbar --> <!-- Accessibility label for clear search query in MainToolbar -->

View File

@@ -392,6 +392,18 @@ class ArchiveApi(
} }
} }
/**
* Determine whether the backup-id can currently be rotated
*
* GET /v1/archives/backupid/limits
* - 200: Successfully retrieved backup-id rotation limits
* - 403: Invalid account authentication
*/
fun getKeyRotationLimit(): NetworkResult<ArchiveKeyRotationLimitResponse> {
val request = WebSocketRequestMessage.get("/v1/archives/backupid/limits")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ArchiveKeyRotationLimitResponse::class)
}
private fun getCredentialPresentation(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<CredentialPresentationData> { private fun getCredentialPresentation(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<CredentialPresentationData> {
return NetworkResult.fromLocal { return NetworkResult.fromLocal {
val zkCredential = getZkCredential(aci, archiveServiceAccess) val zkCredential = getZkCredential(aci, archiveServiceAccess)

View File

@@ -0,0 +1,11 @@
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents the response when fetching the archive backup key rotation limits
*/
data class ArchiveKeyRotationLimitResponse(
@JsonProperty val hasPermitsRemaining: Boolean?,
@JsonProperty val retryAfterSeconds: Long?
)