Add `/v1/archives/redeem-receipt`
This commit is contained in:
parent
fc1f471369
commit
e5d654f0c7
|
@ -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(),
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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));
|
||||
|
|
Loading…
Reference in New Issue