Add `/v1/archives/redeem-receipt`

This commit is contained in:
ravi-signal 2024-04-15 13:47:02 -05:00 committed by GitHub
parent fc1f471369
commit e5d654f0c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 506 additions and 55 deletions

View File

@ -710,7 +710,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams);
Cdn3BackupCredentialGenerator cdn3BackupCredentialGenerator = new Cdn3BackupCredentialGenerator(config.getTus());
BackupAuthManager backupAuthManager = new BackupAuthManager(experimentEnrollmentManager, rateLimiters, accountsManager, backupsGenericZkSecretParams, clock);
BackupAuthManager backupAuthManager = new BackupAuthManager(experimentEnrollmentManager, rateLimiters,
accountsManager, zkReceiptOperations, redeemedReceiptsManager, backupsGenericZkSecretParams, clock);
BackupsDb backupsDb = new BackupsDb(
dynamoDbAsyncClient,
config.getDynamoDbTables().getBackups().getTableName(),

View File

@ -11,22 +11,29 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
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.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.util.Util;
import javax.annotation.Nullable;
/**
* Issues ZK backup auth credentials for authenticated accounts
@ -40,12 +47,17 @@ import org.whispersystems.textsecuregcm.util.Util;
*/
public class BackupAuthManager {
private static final Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);
private static final Logger logger = LoggerFactory.getLogger(BackupManager.class);
final static Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);
final static String BACKUP_EXPERIMENT_NAME = "backup";
final static String BACKUP_MEDIA_EXPERIMENT_NAME = "backupMedia";
private final ExperimentEnrollmentManager experimentEnrollmentManager;
private final GenericServerSecretParams serverSecretParams;
private final ServerZkReceiptOperations serverZkReceiptOperations;
private final RedeemedReceiptsManager redeemedReceiptsManager;
private final Clock clock;
private final RateLimiters rateLimiters;
private final AccountsManager accountsManager;
@ -54,11 +66,15 @@ public class BackupAuthManager {
final ExperimentEnrollmentManager experimentEnrollmentManager,
final RateLimiters rateLimiters,
final AccountsManager accountsManager,
final ServerZkReceiptOperations serverZkReceiptOperations,
final RedeemedReceiptsManager redeemedReceiptsManager,
final GenericServerSecretParams serverSecretParams,
final Clock clock) {
this.experimentEnrollmentManager = experimentEnrollmentManager;
this.rateLimiters = rateLimiters;
this.accountsManager = accountsManager;
this.serverZkReceiptOperations = serverZkReceiptOperations;
this.redeemedReceiptsManager = redeemedReceiptsManager;
this.serverSecretParams = serverSecretParams;
this.clock = clock;
}
@ -66,14 +82,14 @@ public class BackupAuthManager {
/**
* Store a credential request containing a blinded backup-id for future use.
*
* @param account The account using the backup-id
* @param account The account using the backup-id
* @param backupAuthCredentialRequest A request containing the blinded backup-id
* @return A future that completes when the credentialRequest has been stored
* @throws RateLimitExceededException If too many backup-ids have been committed
*/
public CompletableFuture<Void> 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 {
* <p>
* This uses a {@link BackupAuthCredentialRequest} previous stored via {@link this#commitBackupId} to generate the
* credentials.
* <p>
* 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<Long> 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<Void> 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<Long> 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<Long> configuredReceiptLevel(final Account account) {
if (inExperiment(BACKUP_MEDIA_EXPERIMENT_NAME, account)) {
return Optional.of(BackupTier.MEDIA.getReceiptLevel());
}

View File

@ -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.
* <p>
* 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<Long, BackupTier> 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<BackupTier> fromReceiptLevel(long receiptLevel) {
public static Optional<BackupTier> fromReceiptLevel(long receiptLevel) {
return Optional.ofNullable(LOOKUP.get(receiptLevel));
}
}

View File

@ -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

View File

@ -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<ReceiptCredentialPresentation> {
@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<Response> 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<BackupAuthCredential> 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<BackupAuthCredentialsResponse> getBackupZKCredentials(
@ReadOnly @Auth AuthenticatedAccount auth,
@Mutable @Auth AuthenticatedAccount auth,
@NotNull @QueryParam("redemptionStartSeconds") Long startSeconds,
@NotNull @QueryParam("redemptionEndSeconds") Long endSeconds) {

View File

@ -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?

View File

@ -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<BackupAuthManager.Credential> 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<Consumer<Account>> 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<Consumer<Account>> 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<Void> 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;
}

View File

@ -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());

View File

@ -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));