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.StatusCodeErrorAction
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
@@ -2063,6 +2064,10 @@ object BackupRepository {
.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,
* 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(
val onCreateNewKeyClick: () -> Unit,
val onTurnOffAndDownloadClick: () -> Unit,
val isOptimizedStorageEnabled: Boolean
val isOptimizedStorageEnabled: Boolean,
val canRotateKey: Boolean
) : MessageBackupsKeyRecordMode
}
@@ -263,10 +264,17 @@ private fun CreateNewKeyButton(
mode: MessageBackupsKeyRecordMode.CreateNewKey
) {
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(
onClick = { displayBottomSheet = true },
onClick = {
if (!mode.canRotateKey) {
displayKeyLimitDialog = true
} else {
displayBottomSheet = true
}
},
modifier = Modifier
.padding(bottom = 24.dp)
.horizontalGutters()
@@ -276,10 +284,16 @@ private fun CreateNewKeyButton(
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__create_new_key))
}
if (displayDialog) {
if (displayKeyLimitDialog) {
KeyLimitExceededDialog(
onClick = { displayKeyLimitDialog = false }
)
}
if (displayDownloadMediaDialog) {
DownloadMediaDialog(
onTurnOffAndDownloadClick = mode.onTurnOffAndDownloadClick,
onCancelClick = { displayDialog = false }
onCancelClick = { displayDownloadMediaDialog = false }
)
}
@@ -291,7 +305,7 @@ private fun CreateNewKeyButton(
CreateNewBackupKeySheetContent(
onContinueClick = {
if (mode.isOptimizedStorageEnabled) {
displayDialog = true
displayDownloadMediaDialog = true
} else {
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(
@UiContext activityContext: Context,
backupKey: String
@@ -470,7 +497,8 @@ private fun MessageBackupsKeyRecordScreenPreview() {
mode = MessageBackupsKeyRecordMode.CreateNewKey(
onCreateNewKeyClick = {},
onTurnOffAndDownloadClick = {},
isOptimizedStorageEnabled = true
isOptimizedStorageEnabled = true,
canRotateKey = true
)
)
}
@@ -507,3 +535,11 @@ private fun DownloadMediaDialogPreview() {
DownloadMediaDialog()
}
}
@DayNightPreviews
@Composable
private fun KeyLimitExceededDialogPreview() {
Previews.Preview {
KeyLimitExceededDialog()
}
}

View File

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

View File

@@ -14,14 +14,21 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.StagedBackupKeyRotations
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.AccountEntropyPool
import org.whispersystems.signalservice.api.NetworkResult
class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
companion object {
private val TAG = Log.tag(BackupKeyDisplayViewModel::class.java)
}
private val internalUiState = MutableStateFlow(BackupKeyDisplayUiState())
val uiState: StateFlow<BackupKeyDisplayUiState> = internalUiState.asStateFlow()
@@ -29,6 +36,10 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler
internalUiState.update { it.copy(keySaveState = newState) }
}
init {
getKeyRotationLimit()
}
fun rotateBackupKey() {
viewModelScope.launch {
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() {
SignalStore.backup.optimizeStorage = false
// TODO - flag to notify when complete.
@@ -73,7 +99,8 @@ data class BackupKeyDisplayUiState(
val keySaveState: BackupKeySaveState? = null,
val isOptimizedStorageEnabled: Boolean = SignalStore.backup.optimizeStorage,
val rotationState: BackupKeyRotationState = BackupKeyRotationState.NOT_STARTED,
val stagedKeyRotations: StagedBackupKeyRotations? = null
val stagedKeyRotations: StagedBackupKeyRotations? = null,
val canRotateKey: Boolean = true
)
enum class BackupKeyRotationState {

View File

@@ -324,6 +324,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
setFragmentResultListener(BackupKeyDisplayFragment.AEP_ROTATION_KEY) { _, bundle ->
val didRotate = bundle.getBoolean(BackupKeyDisplayFragment.AEP_ROTATION_KEY, false)
if (didRotate) {
viewModel.getKeyRotationLimit()
viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.AEP_KEY_ROTATED)
}
}
@@ -656,6 +657,16 @@ private fun RemoteBackupsSettingsContent(
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) {

View File

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

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.withContext
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.mebiBytes
import org.signal.core.util.throttleLatest
@@ -232,6 +233,22 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
_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() {
viewModelScope.launch(Dispatchers.IO) {
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id

View File

@@ -8783,6 +8783,16 @@
<!-- No backup to Restore tonal cta to skip restore -->
<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 -->
<string name="MainToolbar__close_search_content_description">Close search</string>
<!-- 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> {
return NetworkResult.fromLocal {
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?
)