From e5d654f0c7ad89cfc5eddae71d043269e9b9e895 Mon Sep 17 00:00:00 2001 From: ravi-signal <99042880+ravi-signal@users.noreply.github.com> Date: Mon, 15 Apr 2024 13:47:02 -0500 Subject: [PATCH] Add `/v1/archives/redeem-receipt` --- .../textsecuregcm/WhisperServerService.java | 3 +- .../backup/BackupAuthManager.java | 150 +++++++++- .../textsecuregcm/backup/BackupTier.java | 14 +- .../SubscriptionConfiguration.java | 19 +- .../controllers/ArchiveController.java | 57 +++- .../textsecuregcm/storage/Account.java | 14 + .../backup/BackupAuthManagerTest.java | 270 +++++++++++++++--- .../backup/BackupAuthTestUtil.java | 2 +- .../controllers/ArchiveControllerTest.java | 32 +++ 9 files changed, 506 insertions(+), 55 deletions(-) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index bd2572af5..5f4a06d86 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -710,7 +710,8 @@ public class WhisperServerService extends Application commitBackupId(final Account account, final BackupAuthCredentialRequest backupAuthCredentialRequest) throws RateLimitExceededException { - if (receiptLevel(account).isEmpty()) { + if (configuredReceiptLevel(account).isEmpty()) { throw Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException(); } @@ -99,6 +115,10 @@ public class BackupAuthManager { *

* This uses a {@link BackupAuthCredentialRequest} previous stored via {@link this#commitBackupId} to generate the * credentials. + *

+ * If the account has a BackupVoucher allowing access to paid backups, credentials with a redemptionTime before the + * voucher's expiration will include paid backup access. If the BackupVoucher exists but is already expired, this + * method will also remove the expired voucher from the account. * * @param account The account to create the credentials for * @param redemptionStart The day (must be truncated to a day boundary) the first credential should be valid @@ -110,8 +130,19 @@ public class BackupAuthManager { final Instant redemptionStart, final Instant redemptionEnd) { - final long receiptLevel = receiptLevel(account).orElseThrow( - () -> Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException()); + // If the account has an expired payment, clear it before continuing + if (hasExpiredVoucher(account)) { + return accountsManager.updateAsync(account, a -> { + // Re-check in case we raced with an update + if (hasExpiredVoucher(a)) { + a.setBackupVoucher(null); + } + }).thenCompose(updated -> getBackupAuthCredentials(updated, redemptionStart, redemptionEnd)); + } + + // If this account isn't allowed some level of backup access via configuration, don't continue + final long configuredReceiptLevel = configuredReceiptLevel(account).orElseThrow(() -> + Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException()); final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS); if (redemptionStart.isAfter(redemptionEnd) || @@ -135,9 +166,14 @@ public class BackupAuthManager { return CompletableFuture.completedFuture(Stream .iterate(redemptionStart, curr -> curr.plus(Duration.ofDays(1))) .takeWhile(redemptionTime -> !redemptionTime.isAfter(redemptionEnd)) - .map(redemption -> new Credential( - credentialReq.issueCredential(redemption, receiptLevel, serverSecretParams), - redemption)) + .map(redemptionTime -> { + // Check if the account has a voucher that's good for a certain receiptLevel at redemption time, otherwise + // use the default receipt level + final long receiptLevel = storedReceiptLevel(account, redemptionTime).orElse(configuredReceiptLevel); + return new Credential( + credentialReq.issueCredential(redemptionTime, receiptLevel, serverSecretParams), + redemptionTime); + }) .toList()); } catch (InvalidInputException e) { throw Status.INTERNAL @@ -147,7 +183,99 @@ public class BackupAuthManager { } } - private Optional receiptLevel(final Account account) { + /** + * Redeem a receipt to enable paid backups on the account. + * + * @param account The account to enable backups on + * @param receiptCredentialPresentation A ZK receipt presentation proving payment + * @return A future that completes successfully when the account has been updated + */ + public CompletableFuture redeemReceipt( + final Account account, + final ReceiptCredentialPresentation receiptCredentialPresentation) { + try { + serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation); + } catch (VerificationFailedException e) { + throw Status.INVALID_ARGUMENT + .withDescription("receipt credential presentation verification failed") + .asRuntimeException(); + } + final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial(); + final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime()); + if (clock.instant().isAfter(receiptExpiration)) { + throw Status.INVALID_ARGUMENT.withDescription("receipt is already expired").asRuntimeException(); + } + + final long receiptLevel = receiptCredentialPresentation.getReceiptLevel(); + + BackupTier.fromReceiptLevel(receiptLevel).filter(BackupTier.MEDIA::equals) + .orElseThrow(() -> Status.INVALID_ARGUMENT + .withDescription("server does not recognize the requested receipt level") + .asRuntimeException()); + + return redeemedReceiptsManager + .put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid()) + .thenCompose(receiptAllowed -> { + if (!receiptAllowed) { + throw Status.INVALID_ARGUMENT + .withDescription("receipt serial is already redeemed") + .asRuntimeException(); + } + return accountsManager.updateAsync(account, a -> { + final Account.BackupVoucher newPayment = new Account.BackupVoucher(receiptLevel, receiptExpiration); + final Account.BackupVoucher existingPayment = a.getBackupVoucher(); + account.setBackupVoucher(merge(existingPayment, newPayment)); + }); + }) + .thenRun(Util.NOOP); + } + + private static Account.BackupVoucher merge(@Nullable final Account.BackupVoucher prev, + final Account.BackupVoucher next) { + if (prev == null) { + return next; + } + + if (next.receiptLevel() != prev.receiptLevel()) { + return next; + } + + // If the new payment has the same receipt level as the old, select the further out of the two expiration times + if (prev.expiration().isAfter(next.expiration())) { + // This should be fairly rare, either a client reused an old receipt or we reduced the validity period + logger.warn( + "Redeemed receipt with an expiration at {} when we've previously had a redemption with a later expiration {}", + next.expiration(), prev.expiration()); + return prev; + } + return next; + } + + private boolean hasExpiredVoucher(final Account account) { + return account.getBackupVoucher() != null && clock.instant().isAfter(account.getBackupVoucher().expiration()); + } + + /** + * Get the receipt level stored in the {@link Account.BackupVoucher} on the account if it's present and not expired. + * + * @param account The account to check + * @param redemptionTime The time to check against the expiration time + * @return The receipt level on the backup voucher, or empty if the account does not have one or it is expired + */ + private Optional storedReceiptLevel(final Account account, final Instant redemptionTime) { + return Optional.ofNullable(account.getBackupVoucher()) + .filter(backupVoucher -> !redemptionTime.isAfter(backupVoucher.expiration())) + .map(Account.BackupVoucher::receiptLevel); + } + + /** + * Get the backup receipt level that should be used by default for this account determined via configuration. + * + * @param account the account to check + * @return If present, the default receipt level that should be used for the account if the account does not have a + * BackupVoucher. Empty if the account should never have backup access + */ + private Optional configuredReceiptLevel(final Account account) { if (inExperiment(BACKUP_MEDIA_EXPERIMENT_NAME, account)) { return Optional.of(BackupTier.MEDIA.getReceiptLevel()); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupTier.java b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupTier.java index 08ba72c44..8d5061327 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupTier.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupTier.java @@ -11,16 +11,22 @@ import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; +/** + * Maps receipt levels to BackupTiers. Existing receipt levels should never be remapped to a different tier. + *

+ * Today, receipt levels 1:1 correspond to tiers, but in the future multiple receipt levels may be accepted for access + * to a single tier. + */ public enum BackupTier { NONE(0), - MESSAGES(10), - MEDIA(20); + MESSAGES(200), + MEDIA(201); private static Map LOOKUP = Arrays.stream(BackupTier.values()) .collect(Collectors.toMap(BackupTier::getReceiptLevel, Function.identity())); private long receiptLevel; - private BackupTier(long receiptLevel) { + BackupTier(long receiptLevel) { this.receiptLevel = receiptLevel; } @@ -28,7 +34,7 @@ public enum BackupTier { return receiptLevel; } - static Optional fromReceiptLevel(long receiptLevel) { + public static Optional fromReceiptLevel(long receiptLevel) { return Optional.ofNullable(LOOKUP.get(receiptLevel)); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java index 85dc76b6e..b3ccb3a69 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java @@ -20,6 +20,7 @@ import java.util.stream.Stream; import javax.validation.Valid; import javax.validation.constraints.Min; import javax.validation.constraints.NotNull; +import org.whispersystems.textsecuregcm.backup.BackupTier; public class SubscriptionConfiguration { @@ -72,9 +73,21 @@ public class SubscriptionConfiguration { } @JsonIgnore - @ValidationMethod(message = "Backup levels and donation levels should not contain the same level identifier") - public boolean areLevelsNonOverlapping() { - return Sets.intersection(backupLevels.keySet(), donationLevels.keySet()).isEmpty(); + @ValidationMethod(message = "Backup levels and donation levels should not intersect") + public boolean areLevelConstraintsSatisfied() { + // We have a tier for all configured backup levels + final boolean backupLevelsMatch = backupLevels.keySet() + .stream() + .allMatch(level -> BackupTier.fromReceiptLevel(level).orElse(BackupTier.NONE) != BackupTier.NONE); + + // None of the donation levels correspond to backup levels + final boolean donationLevelsDontMatch = donationLevels.keySet().stream() + .allMatch(level -> BackupTier.fromReceiptLevel(level).orElse(BackupTier.NONE) == BackupTier.NONE); + + // The configured donation and backup levels don't intersect + final boolean levelsDontIntersect = Sets.intersection(backupLevels.keySet(), donationLevels.keySet()).isEmpty(); + + return backupLevelsMatch && donationLevelsDontMatch && levelsDontIntersect; } @JsonIgnore 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 36dd12f76..00b4e7c31 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java @@ -5,6 +5,9 @@ package org.whispersystems.textsecuregcm.controllers; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.dropwizard.auth.Auth; @@ -48,6 +51,7 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation; import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser; import org.whispersystems.textsecuregcm.backup.BackupAuthManager; @@ -117,6 +121,51 @@ public class ArchiveController { .thenApply(Util.ASYNC_EMPTY_RESPONSE); } + public record RedeemReceiptRequest( + @Schema(description = "Presentation of a ZK receipt encoded in standard padded base64", implementation = String.class) + @JsonDeserialize(using = RedeemReceiptRequest.Deserializer.class) + @NotNull + ReceiptCredentialPresentation receiptCredentialPresentation) { + + public static class Deserializer extends JsonDeserializer { + + @Override + public ReceiptCredentialPresentation deserialize(JsonParser jsonParser, + DeserializationContext deserializationContext) throws IOException { + try { + return new ReceiptCredentialPresentation(Base64.getDecoder().decode(jsonParser.getValueAsString())); + } catch (InvalidInputException e) { + throw new IllegalArgumentException(e); + } + } + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Path("/redeem-receipt") + @Operation( + summary = "Redeem receipt", + description = """ + Redeem a receipt acquired from /v1/subscription/{subscriberId}/receipt_credentials to mark the account as + eligible for the paid backup tier. + + After successful redemption, subsequent requests to /v1/archive/auth will return credentials with the level on + the provided receipt until the expiration time on the receipt. + """) + @ApiResponse(responseCode = "204", description = "The receipt was redeemed") + @ApiResponse(responseCode = "400", description = "The provided presentation or receipt was invalid") + @ApiResponse(responseCode = "429", description = "Rate limited.") + public CompletionStage redeemReceipt( + @Mutable @Auth final AuthenticatedAccount account, + @Valid @NotNull final RedeemReceiptRequest redeemReceiptRequest) { + return this.backupAuthManager.redeemReceipt( + account.getAccount(), + redeemReceiptRequest.receiptCredentialPresentation()) + .thenApply(Util.ASYNC_EMPTY_RESPONSE); + } + public record BackupAuthCredentialsResponse( @Schema(description = "A list of BackupAuthCredentials and their validity periods") List credentials) { @@ -138,13 +187,19 @@ public class ArchiveController { operations against that backup-id. Clients may (and should) request up to 7 days of credentials at a time. The redemptionStart and redemptionEnd seconds must be UTC day aligned, and must not span more than 7 days. + + Each credential contains a receipt level which indicates the backup level the credential is good for. If the + account has paid backup access that expires at some point in the provided redemption window, credentials with + redemption times after the expiration may be on a lower backup level. + + Clients must validate the receipt level on the credential matches a known receipt level before using it. """) @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BackupAuthCredentialsResponse.class))) @ApiResponse(responseCode = "400", description = "The start/end did not meet alignment/duration requirements") @ApiResponse(responseCode = "404", description = "Could not find an existing blinded backup id") @ApiResponse(responseCode = "429", description = "Rate limited.") public CompletionStage getBackupZKCredentials( - @ReadOnly @Auth AuthenticatedAccount auth, + @Mutable @Auth AuthenticatedAccount auth, @NotNull @QueryParam("redemptionStartSeconds") Long startSeconds, @NotNull @QueryParam("redemptionEndSeconds") Long endSeconds) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java index 431e37ea9..bacbeae8b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java @@ -102,8 +102,13 @@ public class Account { private boolean discoverableByPhoneNumber = true; @JsonProperty("bcr") + @Nullable private byte[] backupCredentialRequest; + @JsonProperty("bv") + @Nullable + private BackupVoucher backupVoucher; + @JsonProperty private int version; @@ -115,6 +120,8 @@ public class Account { public record UsernameHold(@JsonProperty("uh") byte[] usernameHash, @JsonProperty("e") long expirationSecs) {} + public record BackupVoucher(@JsonProperty("rl") long receiptLevel, @JsonProperty("e") Instant expiration) {} + public UUID getIdentifier(final IdentityType identityType) { return switch (identityType) { case ACI -> getUuid(); @@ -506,6 +513,13 @@ public class Account { this.backupCredentialRequest = backupCredentialRequest; } + public @Nullable BackupVoucher getBackupVoucher() { + return backupVoucher; + } + + public void setBackupVoucher(final @Nullable BackupVoucher backupVoucher) { + this.backupVoucher = backupVoucher; + } /** * Have all this account's devices been manually locked? 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 f83887026..5769f1780 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java @@ -9,8 +9,14 @@ 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.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import io.grpc.Status; @@ -21,6 +27,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; import java.util.stream.Stream; import org.assertj.core.api.Assertions; import org.assertj.core.api.ThrowableAssert; @@ -30,40 +37,63 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.ServerSecretParams; import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest; import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext; +import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredential; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; +import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; +import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager; import org.whispersystems.textsecuregcm.tests.util.ExperimentHelper; +import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestRandomUtil; public class BackupAuthManagerTest { + private final UUID aci = UUID.randomUUID(); private final byte[] backupKey = TestRandomUtil.nextBytes(32); + private final ServerSecretParams receiptParams = ServerSecretParams.generate(); private final TestClock clock = TestClock.now(); private final BackupAuthTestUtil backupAuthTestUtil = new BackupAuthTestUtil(clock); + private final AccountsManager accountsManager = mock(AccountsManager.class); + private final RedeemedReceiptsManager redeemedReceiptsManager = mock(RedeemedReceiptsManager.class); @BeforeEach void setUp() { clock.unpin(); + reset(accountsManager); + reset(redeemedReceiptsManager); } + BackupAuthManager create(BackupTier backupTier, boolean rateLimit) { + return new BackupAuthManager( + ExperimentHelper.withEnrollment(experimentName(backupTier), aci), + rateLimit ? denyRateLimiter(aci) : allowRateLimiter(), + accountsManager, + new ServerZkReceiptOperations(receiptParams), + redeemedReceiptsManager, + backupAuthTestUtil.params, + clock); + } @ParameterizedTest @EnumSource void commitRequiresBackupTier(final BackupTier backupTier) { - final AccountsManager accountsManager = mock(AccountsManager.class); - final BackupAuthManager authManager = new BackupAuthManager( - ExperimentHelper.withEnrollment(experimentName(backupTier), aci), - allowRateLimiter(), - accountsManager, - backupAuthTestUtil.params, - clock); + final BackupAuthManager authManager = create(backupTier, false); final Account account = mock(Account.class); when(account.getUuid()).thenReturn(aci); when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account)); @@ -84,12 +114,7 @@ public class BackupAuthManagerTest { @ParameterizedTest @EnumSource void credentialsRequiresBackupTier(final BackupTier backupTier) { - final BackupAuthManager authManager = new BackupAuthManager( - ExperimentHelper.withEnrollment(experimentName(backupTier), aci), - allowRateLimiter(), - mock(AccountsManager.class), - backupAuthTestUtil.params, - clock); + final BackupAuthManager authManager = create(backupTier, false); final Account account = mock(Account.class); when(account.getUuid()).thenReturn(aci); @@ -113,12 +138,7 @@ public class BackupAuthManagerTest { @ParameterizedTest @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"NONE"}) void getReceiptCredentials(final BackupTier backupTier) throws VerificationFailedException { - final BackupAuthManager authManager = new BackupAuthManager( - ExperimentHelper.withEnrollment(experimentName(backupTier), aci), - allowRateLimiter(), - mock(AccountsManager.class), - backupAuthTestUtil.params, - clock); + final BackupAuthManager authManager = create(backupTier, false); final BackupAuthCredentialRequestContext requestContext = BackupAuthCredentialRequestContext.create(backupKey, aci); @@ -165,12 +185,7 @@ public class BackupAuthManagerTest { @MethodSource void invalidCredentialTimeWindows(final Instant requestRedemptionStart, final Instant requestRedemptionEnd, final Instant now) { - final BackupAuthManager authManager = new BackupAuthManager( - ExperimentHelper.withEnrollment(experimentName(BackupTier.MESSAGES), aci), - allowRateLimiter(), - mock(AccountsManager.class), - backupAuthTestUtil.params, - clock); + final BackupAuthManager authManager = create(BackupTier.MESSAGES, false); final Account account = mock(Account.class); when(account.getUuid()).thenReturn(aci); @@ -185,14 +200,197 @@ public class BackupAuthManagerTest { } @Test - void testRateLimits() throws RateLimitExceededException { + void expiringBackupPayment() throws VerificationFailedException { + clock.pin(Instant.ofEpochSecond(1)); + final Instant day0 = Instant.EPOCH; + final Instant day4 = Instant.EPOCH.plus(Duration.ofDays(4)); + final Instant dayMax = day0.plus(BackupAuthManager.MAX_REDEMPTION_DURATION); + + final BackupAuthManager authManager = create(BackupTier.MESSAGES, false); + + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(aci); + when(account.getBackupCredentialRequest()).thenReturn(backupAuthTestUtil.getRequest(backupKey, aci).serialize()); + when(account.getBackupVoucher()).thenReturn(new Account.BackupVoucher(BackupTier.MEDIA.getReceiptLevel(), day4)); + + final List creds = authManager.getBackupAuthCredentials(account, day0, dayMax).join(); + Instant redemptionTime = day0; + final BackupAuthCredentialRequestContext requestContext = BackupAuthCredentialRequestContext.create(backupKey, aci); + for (int i = 0; i < creds.size(); i++) { + // Before the expiration, credentials should have a media receipt, otherwise messages only + final long level = i < 5 ? BackupTier.MEDIA.getReceiptLevel() : BackupTier.MESSAGES.getReceiptLevel(); + final BackupAuthManager.Credential cred = creds.get(i); + requestContext.receiveResponse(cred.credential(), backupAuthTestUtil.params.getPublicParams(), level); + assertThat(cred.redemptionTime().getEpochSecond()).isEqualTo(redemptionTime.getEpochSecond()); + redemptionTime = redemptionTime.plus(Duration.ofDays(1)); + } + } + + @Test + void expiredBackupPayment() { + final Instant day1 = Instant.EPOCH.plus(Duration.ofDays(1)); + final Instant day2 = Instant.EPOCH.plus(Duration.ofDays(2)); + final Instant day3 = Instant.EPOCH.plus(Duration.ofDays(3)); + + final BackupAuthManager authManager = create(BackupTier.MESSAGES, false); + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(aci); + when(account.getBackupVoucher()).thenReturn(new Account.BackupVoucher(3, day1)); + + final Account updated = mock(Account.class); + when(updated.getUuid()).thenReturn(aci); + when(updated.getBackupCredentialRequest()).thenReturn(backupAuthTestUtil.getRequest(backupKey, aci).serialize()); + when(updated.getBackupVoucher()).thenReturn(null); + when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(updated)); + + clock.pin(day2.plus(Duration.ofSeconds(1))); + assertThat(authManager.getBackupAuthCredentials(account, day2, day2.plus(Duration.ofDays(7))).join()) + .hasSize(8); + + @SuppressWarnings("unchecked") + final ArgumentCaptor> accountUpdater = ArgumentCaptor.forClass(Consumer.class); + verify(accountsManager, times(1)).updateAsync(any(), accountUpdater.capture()); + + // If the account is not expired when we go to update it, we shouldn't wipe it out + final Account alreadyUpdated = mock(Account.class); + when(alreadyUpdated.getBackupVoucher()).thenReturn(new Account.BackupVoucher(3, day3)); + accountUpdater.getValue().accept(alreadyUpdated); + verify(alreadyUpdated, never()).setBackupVoucher(any()); + + // If the account is still expired when we go to update it, we can wipe it out + final Account expired = mock(Account.class); + when(expired.getBackupVoucher()).thenReturn(new Account.BackupVoucher(3, day1)); + accountUpdater.getValue().accept(expired); + verify(expired, times(1)).setBackupVoucher(null); + } + + + @Test + void redeemReceipt() throws InvalidInputException, VerificationFailedException { + final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1)); + final BackupAuthManager authManager = create(BackupTier.MESSAGES, false); + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(aci); + + clock.pin(Instant.EPOCH.plus(Duration.ofDays(1))); + when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account)); + when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci))) + .thenReturn(CompletableFuture.completedFuture(true)); + authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)).join(); + verify(accountsManager, times(1)).updateAsync(any(), any()); + } + + @Test + void mergeRedemptions() throws InvalidInputException, VerificationFailedException { + final Instant newExpirationTime = Instant.EPOCH.plus(Duration.ofDays(1)); + final Instant existingExpirationTime = Instant.EPOCH.plus(Duration.ofDays(1)).plus(Duration.ofSeconds(1)); + + + final BackupAuthManager authManager = create(BackupTier.MESSAGES, false); + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(aci); + + // The account has an existing voucher with a later expiration date + when(account.getBackupVoucher()).thenReturn(new Account.BackupVoucher(201, existingExpirationTime)); + + clock.pin(Instant.EPOCH.plus(Duration.ofDays(1))); + when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account)); + when(redeemedReceiptsManager.put(any(), eq(newExpirationTime.getEpochSecond()), eq(201L), eq(aci))) + .thenReturn(CompletableFuture.completedFuture(true)); + authManager.redeemReceipt(account, receiptPresentation(201, newExpirationTime)).join(); + + final ArgumentCaptor> updaterCaptor = ArgumentCaptor.captor(); + verify(accountsManager, times(1)).updateAsync(any(), updaterCaptor.capture()); + + updaterCaptor.getValue().accept(account); + // Should select the voucher with the later expiration time + verify(account).setBackupVoucher(eq(new Account.BackupVoucher(201, existingExpirationTime))); + } + + @Test + void redeemExpiredReceipt() { + final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1)); + clock.pin(expirationTime.plus(Duration.ofSeconds(1))); + final BackupAuthManager authManager = create(BackupTier.MESSAGES, false); + Assertions.assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), receiptPresentation(3, expirationTime)).join()) + .extracting(ex -> ex.getStatus().getCode()) + .isEqualTo(Status.Code.INVALID_ARGUMENT); + verifyNoInteractions(accountsManager); + verifyNoInteractions(redeemedReceiptsManager); + } + + @ParameterizedTest + @ValueSource(longs = {0, 1, 2, 200, 500}) + void redeemInvalidLevel(long level) { + final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1)); + clock.pin(expirationTime.plus(Duration.ofSeconds(1))); + final BackupAuthManager authManager = create(BackupTier.MESSAGES, false); + Assertions.assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(() -> + authManager.redeemReceipt(mock(Account.class), receiptPresentation(level, expirationTime)).join()) + .extracting(ex -> ex.getStatus().getCode()) + .isEqualTo(Status.Code.INVALID_ARGUMENT); + verifyNoInteractions(accountsManager); + verifyNoInteractions(redeemedReceiptsManager); + } + + @Test + void redeemInvalidPresentation() throws InvalidInputException, VerificationFailedException { + final BackupAuthManager authManager = create(BackupTier.MESSAGES, false); + final ReceiptCredentialPresentation invalid = receiptPresentation(ServerSecretParams.generate(), 3L, Instant.EPOCH); + Assertions.assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), invalid).join()) + .extracting(ex -> ex.getStatus().getCode()) + .isEqualTo(Status.Code.INVALID_ARGUMENT); + verifyNoInteractions(accountsManager); + verifyNoInteractions(redeemedReceiptsManager); + } + + @Test + void receiptAlreadyRedeemed() throws InvalidInputException, VerificationFailedException { + final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1)); + final BackupAuthManager authManager = create(BackupTier.MESSAGES, false); + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(aci); + + clock.pin(Instant.EPOCH.plus(Duration.ofDays(1))); + when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account)); + when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci))) + .thenReturn(CompletableFuture.completedFuture(false)); + + final CompletableFuture result = authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)); + assertThat(CompletableFutureTestUtil.assertFailsWithCause(StatusRuntimeException.class, result)) + .extracting(ex -> ex.getStatus().getCode()) + .isEqualTo(Status.Code.INVALID_ARGUMENT); + verifyNoInteractions(accountsManager); + } + + private ReceiptCredentialPresentation receiptPresentation(long level, Instant redemptionTime) + throws InvalidInputException, VerificationFailedException { + return receiptPresentation(receiptParams, level, redemptionTime); + } + + private ReceiptCredentialPresentation receiptPresentation(ServerSecretParams params, long level, + Instant redemptionTime) + throws InvalidInputException, VerificationFailedException { + final ServerZkReceiptOperations serverOps = new ServerZkReceiptOperations(params); + final ClientZkReceiptOperations clientOps = new ClientZkReceiptOperations(params.getPublicParams()); + + final ReceiptCredentialRequestContext rcrc = clientOps + .createReceiptCredentialRequestContext(new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE))); + + final ReceiptCredentialResponse response = + serverOps.issueReceiptCredential(rcrc.getRequest(), redemptionTime.getEpochSecond(), level); + final ReceiptCredential receiptCredential = clientOps.receiveReceiptCredential(rcrc, response); + return clientOps.createReceiptCredentialPresentation(receiptCredential); + } + + + @Test + void testRateLimits() { final AccountsManager accountsManager = mock(AccountsManager.class); - final BackupAuthManager authManager = new BackupAuthManager( - ExperimentHelper.withEnrollment(experimentName(BackupTier.MESSAGES), aci), - denyRateLimiter(aci), - accountsManager, - backupAuthTestUtil.params, - clock); + final BackupAuthManager authManager = create(BackupTier.MESSAGES, true); final BackupAuthCredentialRequest credentialRequest = backupAuthTestUtil.getRequest(backupKey, aci); @@ -224,10 +422,14 @@ public class BackupAuthManagerTest { return limiters; } - private static RateLimiters denyRateLimiter(final UUID aci) throws RateLimitExceededException { + private static RateLimiters denyRateLimiter(final UUID aci) { final RateLimiters limiters = mock(RateLimiters.class); final RateLimiter limiter = mock(RateLimiter.class); - doThrow(new RateLimitExceededException(null, false)).when(limiter).validate(aci); + try { + doThrow(new RateLimitExceededException(null, false)).when(limiter).validate(aci); + } catch (RateLimitExceededException e) { + throw new AssertionError(e); + } when(limiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID)).thenReturn(limiter); return limiters; } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthTestUtil.java b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthTestUtil.java index 4dbfdbfde..ddd8b70cd 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthTestUtil.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthTestUtil.java @@ -65,7 +65,7 @@ public class BackupAuthTestUtil { case MEDIA -> BackupAuthManager.BACKUP_MEDIA_EXPERIMENT_NAME; }; final BackupAuthManager issuer = new BackupAuthManager( - ExperimentHelper.withEnrollment(experimentName, aci), null, null, params, clock); + ExperimentHelper.withEnrollment(experimentName, aci), null, null, null, null, params, clock); Account account = mock(Account.class); when(account.getUuid()).thenReturn(aci); when(account.getBackupCredentialRequest()).thenReturn(request.serialize()); 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 0991724da..76b121e3c 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java @@ -49,8 +49,17 @@ import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.junitpioneer.jupiter.cartesian.CartesianTest; import org.signal.libsignal.protocol.ecc.Curve; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.ServerSecretParams; import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation; +import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredential; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; +import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; +import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser; import org.whispersystems.textsecuregcm.backup.BackupAuthManager; @@ -152,6 +161,29 @@ public class ArchiveControllerTest { assertThat(response.getStatus()).isEqualTo(204); } + @Test + public void redeemReceipt() throws InvalidInputException, VerificationFailedException { + final ServerSecretParams params = ServerSecretParams.generate(); + final ServerZkReceiptOperations serverOps = new ServerZkReceiptOperations(params); + final ClientZkReceiptOperations clientOps = new ClientZkReceiptOperations(params.getPublicParams()); + final ReceiptCredentialRequestContext rcrc = clientOps + .createReceiptCredentialRequestContext(new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE))); + final ReceiptCredentialResponse rcr = serverOps.issueReceiptCredential(rcrc.getRequest(), 0L, 3L); + final ReceiptCredential receiptCredential = clientOps.receiveReceiptCredential(rcrc, rcr); + final ReceiptCredentialPresentation presentation = clientOps.createReceiptCredentialPresentation(receiptCredential); + when(backupAuthManager.redeemReceipt(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + final Response response = resources.getJerseyTest() + .target("v1/archives/redeem-receipt") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(Entity.json(""" + {"receiptCredentialPresentation": "%s"} + """.formatted(Base64.getEncoder().encodeToString(presentation.serialize())))); + assertThat(response.getStatus()).isEqualTo(204); + } + + @Test public void setBadPublicKey() throws VerificationFailedException { when(backupManager.setPublicKey(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));