diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java index 77270ad17..b3a829c2e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java @@ -11,6 +11,8 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -31,6 +33,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; +import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; @@ -134,6 +137,32 @@ public class BackupAuthManager { .toCompletableFuture(); } + public record BackupIdRotationLimit(boolean hasPermitsRemaining, Duration nextPermitAvailable) {} + + public CompletionStage checkBackupIdRotationLimit(final Account account) { + final RateLimiter messagesLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID); + final RateLimiter mediaLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID); + + final boolean isPaid = hasActiveVoucher(account); + + final CompletionStage hasSetMessagesPermits = + messagesLimiter.hasAvailablePermitsAsync(account.getUuid(), 1); + final CompletionStage hasSetMediaPermits = isPaid + ? mediaLimiter.hasAvailablePermitsAsync(account.getUuid(), 1) + : CompletableFuture.completedFuture(true); + + return hasSetMessagesPermits.thenCombine(hasSetMediaPermits, (hasMessage, hasMedia) -> { + if (hasMedia && hasMessage) { + return new BackupIdRotationLimit(true, Duration.ZERO); + } else { + final Duration timeToNextPermit = Collections.max(Arrays.asList( + messagesLimiter.config().permitRegenerationDuration(), + isPaid ? mediaLimiter.config().permitRegenerationDuration() : Duration.ZERO)); + return new BackupIdRotationLimit(false, timeToNextPermit); + } + }); + } + public record Credential(BackupAuthCredentialResponse credential, Instant redemptionTime) {} /** diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java index a25323b21..a1f7a4ed2 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java @@ -163,6 +163,34 @@ public class ArchiveController { }); } + + public record BackupIdLimitResponse( + @Schema(description = "If true, a call to PUT /v1/archive/backupid may succeed without waiting") + boolean hasPermitsRemaining, + @Schema(description = "How long to wait before a permit becomes available, in seconds") + long retryAfterSeconds) {} + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/backupid/limits") + @Operation( + summary = "Retrieve limits", + description = """ + Determine whether the backup-id can currently be rotated + """) + @ApiResponse(responseCode = "200", description = "Successfully retrieved backup-id rotation limits", useReturnTypeSchema = true) + @ApiResponse(responseCode = "403", description = "Invalid account authentication") + public CompletionStage checkLimits(@Auth final AuthenticatedDevice authenticatedDevice) { + return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()) + .thenCompose(maybeAccount -> { + final Account account = maybeAccount + .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)); + + return backupAuthManager.checkBackupIdRotationLimit(account).thenApply(limit -> + new BackupIdLimitResponse(limit.hasPermitsRemaining(), limit.nextPermitAvailable().getSeconds())); + }); + } + public record RedeemBackupReceiptRequest( @Schema(description = "Presentation of a ZK receipt encoded in standard padded base64", implementation = String.class) @JsonDeserialize(using = Deserializer.class) diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java index 91c27ddf7..66cef5836 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java @@ -9,6 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -57,6 +58,7 @@ import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiterConfig; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; @@ -511,6 +513,26 @@ public class BackupAuthManagerTest { return clientOps.createReceiptCredentialPresentation(receiptCredential); } + @CartesianTest + void testCheckLimits( + @CartesianTest.Values(booleans = {true, false}) boolean messageLimited, + @CartesianTest.Values(booleans = {true, false}) boolean mediaLimited, + @CartesianTest.Values(booleans = {true, false}) boolean hasVoucher) { + clock.pin(Instant.EPOCH); + final BackupAuthManager authManager = create(BackupLevel.FREE, rateLimiter(aci, messageLimited, mediaLimited)); + final Account account = new MockAccountBuilder() + .backupVoucher(hasVoucher + ? new Account.BackupVoucher(1, Instant.EPOCH.plus(Duration.ofSeconds(1))) + : null) + .build(); + final BackupAuthManager.BackupIdRotationLimit limit = authManager.checkBackupIdRotationLimit(account) + .toCompletableFuture().join(); + final boolean expectHasPermits = !messageLimited && (!mediaLimited || !hasVoucher); + final Duration expectedDuration = expectHasPermits ? Duration.ZERO : Duration.ofDays(1); + assertThat(limit.hasPermitsRemaining()).isEqualTo(expectHasPermits); + assertThat(limit.nextPermitAvailable()).isEqualTo(expectedDuration); + } + @CartesianTest void testChangeIdRateLimits( @@ -643,11 +665,15 @@ public class BackupAuthManagerTest { final RateLimiters limiters = mock(RateLimiters.class); final RateLimiter allowLimiter = mock(RateLimiter.class); + when(allowLimiter.hasAvailablePermitsAsync(eq(aci), anyInt())).thenReturn(CompletableFuture.completedFuture(true)); when(allowLimiter.validateAsync(aci)).thenReturn(CompletableFuture.completedFuture(null)); + when(allowLimiter.config()).thenReturn(new RateLimiterConfig(1, Duration.ofDays(1), false)); final RateLimiter denyLimiter = mock(RateLimiter.class); + when(denyLimiter.hasAvailablePermitsAsync(eq(aci), anyInt())).thenReturn(CompletableFuture.completedFuture(false)); when(denyLimiter.validateAsync(aci)) .thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null))); + when(denyLimiter.config()).thenReturn(new RateLimiterConfig(1, Duration.ofDays(1), false)); when(limiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID)) .thenReturn(rateLimitBackupId ? denyLimiter : allowLimiter); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java index 241345d51..fe95dadce 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java @@ -182,6 +182,27 @@ public class ArchiveControllerTest { backupAuthTestUtil.getRequest(mediaBackupKey, aci)); } + @ParameterizedTest + @CsvSource({ + "true, 0", + "false, 1", + "false, 12345" + }) + public void backupIdLimits(boolean hasPermits, long waitSeconds) { + when(backupAuthManager.checkBackupIdRotationLimit(any())) + .thenReturn(CompletableFuture.completedFuture( + new BackupAuthManager.BackupIdRotationLimit(hasPermits, Duration.ofSeconds(waitSeconds)))); + + final ArchiveController.BackupIdLimitResponse response = resources.getJerseyTest() + .target("v1/archives/backupid/limits") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .get(ArchiveController.BackupIdLimitResponse.class); + + assertThat(response.hasPermitsRemaining()).isEqualTo(hasPermits); + assertThat(response.retryAfterSeconds()).isEqualTo(waitSeconds); + } + @Test public void redeemReceipt() throws InvalidInputException, VerificationFailedException { final ServerSecretParams params = ServerSecretParams.generate();