Make Backup methods synchronous

This commit is contained in:
ravi-signal
2026-01-21 15:01:58 -05:00
committed by GitHub
parent b4db86a39b
commit 023296feaf
16 changed files with 640 additions and 626 deletions

View File

@@ -40,7 +40,6 @@ import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.util.Util;
/**
* Issues ZK backup auth credentials for authenticated accounts
@@ -57,7 +56,6 @@ public class BackupAuthManager {
private static final Logger logger = LoggerFactory.getLogger(BackupAuthManager.class);
final static Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);
final static String BACKUP_MEDIA_EXPERIMENT_NAME = "backupMedia";
private final ExperimentEnrollmentManager experimentEnrollmentManager;
@@ -94,14 +92,13 @@ public class BackupAuthManager {
* message backups
* @param mediaBackupCredentialRequest A request containing the blinded backup-id the client will use to upload
* media backups
* @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(
public void commitBackupId(
final Account account,
final Device device,
final Optional<BackupAuthCredentialRequest> messagesBackupCredentialRequest,
final Optional<BackupAuthCredentialRequest> mediaBackupCredentialRequest) {
final Optional<BackupAuthCredentialRequest> mediaBackupCredentialRequest) throws RateLimitExceededException {
if (!device.isPrimary()) {
throw Status.PERMISSION_DENIED.withDescription("Only primary device can set backup-id").asRuntimeException();
}
@@ -133,33 +130,24 @@ public class BackupAuthManager {
if (!requiresMessageRotation && !requiresMediaRotation) {
// No need to update or enforce rate limits, this is the credential that the user has already
// committed to.
return CompletableFuture.completedFuture(null);
return;
}
CompletableFuture<Void> rateLimitFuture = CompletableFuture.completedFuture(null);
if (requiresMessageRotation) {
rateLimitFuture = rateLimitFuture.thenCombine(
rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID).validateAsync(account.getUuid()),
(_, _) -> null);
rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID).validate(account.getUuid());
}
if (requiresMediaRotation && hasActiveVoucher(account)) {
rateLimitFuture = rateLimitFuture.thenCombine(
rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID).validateAsync(account.getUuid()),
(_, _) -> null);
rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID).validate(account.getUuid());
}
return rateLimitFuture.thenCompose(ignored -> this.accountsManager
.updateAsync(account, a ->
a.setBackupCredentialRequests(targetMessageCredentialRequest, targetMediaCredentialRequest))
.thenRun(Util.NOOP))
.toCompletableFuture();
this.accountsManager.update(account, a ->
a.setBackupCredentialRequests(targetMessageCredentialRequest, targetMediaCredentialRequest));
}
public record BackupIdRotationLimit(boolean hasPermitsRemaining, Duration nextPermitAvailable) {}
public CompletionStage<BackupIdRotationLimit> checkBackupIdRotationLimit(final Account account) {
public BackupIdRotationLimit checkBackupIdRotationLimit(final Account account) {
final RateLimiter messagesLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID);
final RateLimiter mediaLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID);
@@ -180,7 +168,7 @@ public class BackupAuthManager {
isPaid ? mediaLimiter.config().permitRegenerationDuration() : Duration.ZERO));
return new BackupIdRotationLimit(false, timeToNextPermit);
}
});
}).toCompletableFuture().join();
}
public record Credential(BackupAuthCredentialResponse credential, Instant redemptionTime) {}
@@ -200,19 +188,20 @@ public class BackupAuthManager {
* @param redemptionRange The time range to return credentials for
* @return Credentials and the day on which they may be redeemed
*/
public CompletableFuture<List<Credential>> getBackupAuthCredentials(
public List<Credential> getBackupAuthCredentials(
final Account account,
final BackupCredentialType credentialType,
final RedemptionRange redemptionRange) {
// If the account has an expired payment, clear it before continuing
if (hasExpiredVoucher(account)) {
return accountsManager.updateAsync(account, a -> {
final Account updated = accountsManager.update(account, a -> {
// Re-check in case we raced with an update
if (hasExpiredVoucher(a)) {
a.setBackupVoucher(null);
}
}).thenCompose(updated -> getBackupAuthCredentials(updated, credentialType, redemptionRange));
});
return getBackupAuthCredentials(updated, credentialType, redemptionRange);
}
// fetch the blinded backup-id the account should have previously committed to
@@ -224,7 +213,7 @@ public class BackupAuthManager {
// create a credential for every day in the requested period
final BackupAuthCredentialRequest credentialReq = new BackupAuthCredentialRequest(committedBytes);
return CompletableFuture.completedFuture(StreamSupport.stream(redemptionRange.spliterator(), false)
return StreamSupport.stream(redemptionRange.spliterator(), false)
.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
@@ -233,7 +222,7 @@ public class BackupAuthManager {
credentialReq.issueCredential(redemptionTime, backupLevel, credentialType, serverSecretParams),
redemptionTime);
})
.toList());
.toList();
} catch (InvalidInputException e) {
throw Status.INTERNAL
.withDescription("Could not deserialize stored request credential")
@@ -247,9 +236,8 @@ public class BackupAuthManager {
*
* @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(
public void redeemReceipt(
final Account account,
final ReceiptCredentialPresentation receiptCredentialPresentation) {
try {
@@ -279,16 +267,15 @@ public class BackupAuthManager {
.asRuntimeException();
}
return redeemedReceiptsManager
boolean receiptAllowed = redeemedReceiptsManager
.put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid())
.thenCompose(receiptAllowed -> {
if (!receiptAllowed) {
throw Status.INVALID_ARGUMENT
.withDescription("receipt serial is already redeemed")
.asRuntimeException();
}
return extendBackupVoucher(account, new Account.BackupVoucher(receiptLevel, receiptExpiration));
});
.join();
if (!receiptAllowed) {
throw Status.INVALID_ARGUMENT
.withDescription("receipt serial is already redeemed")
.asRuntimeException();
}
extendBackupVoucher(account, new Account.BackupVoucher(receiptLevel, receiptExpiration));
}
/**
@@ -296,11 +283,9 @@ public class BackupAuthManager {
*
* @param account The account to update
* @param backupVoucher The backup voucher to apply to this account
* @return A future that completes once the account has been updated to have at least the level and expiration
* in the provided voucher.
*/
public CompletableFuture<Void> extendBackupVoucher(final Account account, final Account.BackupVoucher backupVoucher) {
return accountsManager.updateAsync(account, a -> {
public void extendBackupVoucher(final Account account, final Account.BackupVoucher backupVoucher) {
accountsManager.update(account, a -> {
// Receipt credential expirations must be day aligned. Make sure any manually set backupVoucher is also day
// aligned
final Account.BackupVoucher newPayment = new Account.BackupVoucher(
@@ -308,7 +293,7 @@ public class BackupAuthManager {
backupVoucher.expiration().truncatedTo(ChronoUnit.DAYS));
final Account.BackupVoucher existingPayment = a.getBackupVoucher();
a.setBackupVoucher(merge(existingPayment, newPayment));
}).thenRun(Util.NOOP);
});
}
private static Account.BackupVoucher merge(@Nullable final Account.BackupVoucher prev,

View File

@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.backup;
import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.util.DataSize;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
@@ -42,12 +43,12 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicBackupConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.AsyncTimerUtil;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
@@ -128,7 +129,7 @@ public class BackupManager {
* @param signature the signature of the presentation
* @param publicKey the public key of a key-pair that the presentation must be signed with
*/
public CompletableFuture<Void> setPublicKey(
public void setPublicKey(
final BackupAuthCredentialPresentation presentation,
final byte[] signature,
final ECPublicKey publicKey) {
@@ -139,8 +140,10 @@ public class BackupManager {
final Pair<BackupCredentialType, BackupLevel> credentialTypeAndBackupLevel =
verifyPresentation(presentation).verifySignature(signature, publicKey);
return backupsDb.setPublicKey(presentation.getBackupId(), credentialTypeAndBackupLevel.second(), publicKey)
.exceptionally(ExceptionUtils.exceptionallyHandler(PublicKeyConflictException.class, ex -> {
ExceptionUtils.unwrapSupply(
PublicKeyConflictException.class,
() -> backupsDb.setPublicKey(presentation.getBackupId(), credentialTypeAndBackupLevel.second(), publicKey).join(),
_ -> {
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
SUCCESS_TAG_NAME, String.valueOf(false),
FAILURE_REASON_TAG_NAME, "public_key_conflict")
@@ -148,7 +151,7 @@ public class BackupManager {
throw Status.UNAUTHENTICATED
.withDescription("public key does not match existing public key for the backup-id")
.asRuntimeException();
}));
});
}
/**
@@ -159,31 +162,27 @@ public class BackupManager {
* @param backupUser an already ZK authenticated backup user
* @return the upload form
*/
public CompletableFuture<BackupUploadDescriptor> createMessageBackupUploadDescriptor(
public BackupUploadDescriptor createMessageBackupUploadDescriptor(
final AuthenticatedBackupUser backupUser) {
checkBackupLevel(backupUser, BackupLevel.FREE);
checkBackupCredentialType(backupUser, BackupCredentialType.MESSAGES);
// this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp
return backupsDb
.addMessageBackup(backupUser)
.thenApply(_ ->
cdn3BackupCredentialGenerator.generateUpload(cdnMessageBackupName(backupUser)));
backupsDb.addMessageBackup(backupUser).join();
return cdn3BackupCredentialGenerator.generateUpload(cdnMessageBackupName(backupUser));
}
public CompletableFuture<BackupUploadDescriptor> createTemporaryAttachmentUploadDescriptor(
final AuthenticatedBackupUser backupUser) {
public BackupUploadDescriptor createTemporaryAttachmentUploadDescriptor(final AuthenticatedBackupUser backupUser)
throws RateLimitExceededException {
checkBackupLevel(backupUser, BackupLevel.PAID);
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
return rateLimiters.forDescriptor(RateLimiters.For.BACKUP_ATTACHMENT)
.validateAsync(rateLimitKey(backupUser)).thenApply(ignored -> {
final byte[] bytes = new byte[15];
secureRandom.nextBytes(bytes);
final String attachmentKey = Base64.getUrlEncoder().encodeToString(bytes);
final AttachmentGenerator.Descriptor descriptor = tusAttachmentGenerator.generateAttachment(attachmentKey);
return new BackupUploadDescriptor(3, attachmentKey, descriptor.headers(), descriptor.signedUploadLocation());
}).toCompletableFuture();
rateLimiters.forDescriptor(RateLimiters.For.BACKUP_ATTACHMENT).validate(rateLimitKey(backupUser));
final byte[] bytes = new byte[15];
secureRandom.nextBytes(bytes);
final String attachmentKey = Base64.getUrlEncoder().encodeToString(bytes);
final AttachmentGenerator.Descriptor descriptor = tusAttachmentGenerator.generateAttachment(attachmentKey);
return new BackupUploadDescriptor(3, attachmentKey, descriptor.headers(), descriptor.signedUploadLocation());
}
/**
@@ -191,36 +190,35 @@ public class BackupManager {
*
* @param backupUser an already ZK authenticated backup user
*/
public CompletableFuture<Void> ttlRefresh(final AuthenticatedBackupUser backupUser) {
public void ttlRefresh(final AuthenticatedBackupUser backupUser) {
checkBackupLevel(backupUser, BackupLevel.FREE);
// update message backup TTL
return backupsDb.ttlRefresh(backupUser).thenAccept(storedBackupAttributes -> {
if (backupUser.credentialType() == BackupCredentialType.MEDIA) {
final long maxTotalMediaSize =
dynamicConfigurationManager.getConfiguration().getBackupConfiguration().maxTotalMediaSize();
final StoredBackupAttributes storedBackupAttributes = backupsDb.ttlRefresh(backupUser).join();
if (backupUser.credentialType() == BackupCredentialType.MEDIA) {
final long maxTotalMediaSize =
dynamicConfigurationManager.getConfiguration().getBackupConfiguration().maxTotalMediaSize();
// Report that the backup is out of quota if it cannot store a max size media object
final boolean quotaExhausted = storedBackupAttributes.bytesUsed() >=
(maxTotalMediaSize - BackupManager.MAX_MEDIA_OBJECT_SIZE);
// Report that the backup is out of quota if it cannot store a max size media object
final boolean quotaExhausted = storedBackupAttributes.bytesUsed() >=
(maxTotalMediaSize - BackupManager.MAX_MEDIA_OBJECT_SIZE);
final Tags tags = Tags.of(
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
Tag.of("type", backupUser.credentialType().name()),
Tag.of("tier", backupUser.backupLevel().name()),
Tag.of("quotaExhausted", String.valueOf(quotaExhausted)));
final Tags tags = Tags.of(
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
Tag.of("type", backupUser.credentialType().name()),
Tag.of("tier", backupUser.backupLevel().name()),
Tag.of("quotaExhausted", String.valueOf(quotaExhausted)));
DistributionSummary.builder(NUM_OBJECTS_SUMMARY_NAME)
.tags(tags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
.record(storedBackupAttributes.numObjects());
DistributionSummary.builder(BYTES_USED_SUMMARY_NAME)
.tags(tags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
.record(storedBackupAttributes.bytesUsed());
}
});
DistributionSummary.builder(NUM_OBJECTS_SUMMARY_NAME)
.tags(tags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
.record(storedBackupAttributes.numObjects());
DistributionSummary.builder(BYTES_USED_SUMMARY_NAME)
.tags(tags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
.record(storedBackupAttributes.bytesUsed());
}
}
public record BackupInfo(int cdn, String backupSubdir, String mediaSubdir, String messageBackupKey,
@@ -232,15 +230,15 @@ public class BackupManager {
* @param backupUser an already ZK authenticated backup user
* @return Information about the existing backup
*/
public CompletableFuture<BackupInfo> backupInfo(final AuthenticatedBackupUser backupUser) {
public BackupInfo backupInfo(final AuthenticatedBackupUser backupUser) {
checkBackupLevel(backupUser, BackupLevel.FREE);
return backupsDb.describeBackup(backupUser)
.thenApply(backupDescription -> new BackupInfo(
backupDescription.cdn(),
backupUser.backupDir(),
backupUser.mediaDir(),
MESSAGE_BACKUP_NAME,
backupDescription.mediaUsedSpace()));
final BackupsDb.BackupDescription backupDescription = backupsDb.describeBackup(backupUser).join();
return new BackupInfo(
backupDescription.cdn(),
backupUser.backupDir(),
backupUser.mediaDir(),
MESSAGE_BACKUP_NAME,
backupDescription.mediaUsedSpace());
}
/**
@@ -461,45 +459,47 @@ public class BackupManager {
* @param limit The maximum number of list results to return
* @return A {@link ListMediaResult}
*/
public CompletionStage<ListMediaResult> list(
public ListMediaResult list(
final AuthenticatedBackupUser backupUser,
final Optional<String> cursor,
final int limit) {
checkBackupLevel(backupUser, BackupLevel.FREE);
return remoteStorageManager.list(cdnMediaDirectory(backupUser), cursor, limit)
.thenApply(result ->
new ListMediaResult(
result
.objects()
.stream()
.map(entry -> new StorageDescriptorWithLength(
remoteStorageManager.cdnNumber(),
decodeMediaIdFromCdn(entry.key()),
entry.length()
))
.toList(),
result.cursor()
));
final RemoteStorageManager.ListResult result =
remoteStorageManager.list(cdnMediaDirectory(backupUser), cursor, limit).toCompletableFuture().join();
return new ListMediaResult(result
.objects()
.stream()
.map(entry -> new StorageDescriptorWithLength(
remoteStorageManager.cdnNumber(),
decodeMediaIdFromCdn(entry.key()),
entry.length()
))
.toList(),
result.cursor());
}
public CompletableFuture<Void> deleteEntireBackup(final AuthenticatedBackupUser backupUser) {
public void deleteEntireBackup(final AuthenticatedBackupUser backupUser) {
checkBackupLevel(backupUser, BackupLevel.FREE);
final int deletionConcurrency =
dynamicConfigurationManager.getConfiguration().getBackupConfiguration().deletionConcurrency();
// Clients only include SVRB data with their messages backup-id
final CompletableFuture<Void> svrbRemoval = switch(backupUser.credentialType()) {
case BackupCredentialType.MESSAGES -> secureValueRecoveryBClient.removeData(svrbIdentifier(backupUser));
case BackupCredentialType.MEDIA -> CompletableFuture.completedFuture(null);
};
return svrbRemoval.thenCompose(_ -> backupsDb
// Try to swap out the backupDir for the user
.scheduleBackupDeletion(backupUser)
if (backupUser.credentialType() == BackupCredentialType.MESSAGES) {
secureValueRecoveryBClient.removeData(svrbIdentifier(backupUser)).join();
}
try {
// Try to swap out the backupDir for the user
backupsDb.scheduleBackupDeletion(backupUser).join();
} catch (Exception e) {
final Throwable unwrapped = ExceptionUtils.unwrap(e);
if (unwrapped instanceof BackupsDb.PendingDeletionException) {
// If there was already a pending swap, try to delete the cdn objects directly
.exceptionallyCompose(ExceptionUtils.exceptionallyHandler(BackupsDb.PendingDeletionException.class, e ->
AsyncTimerUtil.record(SYNCHRONOUS_DELETE_TIMER, () ->
deletePrefix(backupUser.backupDir(), deletionConcurrency)))));
SYNCHRONOUS_DELETE_TIMER.record(() -> deletePrefix(backupUser.backupDir(), deletionConcurrency).join());
} else {
throw e;
}
}
}
@@ -615,11 +615,34 @@ public class BackupManager {
* @param signature An XEd25519 signature of the presentation bytes
* @return On authentication success, the authenticated backup-id and backup-tier encoded in the presentation
*/
public CompletableFuture<AuthenticatedBackupUser> authenticateBackupUser(
public AuthenticatedBackupUser authenticateBackupUser(
final BackupAuthCredentialPresentation presentation,
final byte[] signature,
final String userAgentString) {
return ExceptionUtils.unwrapSupply(
StatusRuntimeException.class,
() -> authenticateBackupUserAsync(presentation, signature, userAgentString).join());
}
/**
* Authenticate the ZK anonymous backup credential's presentation
* <p>
* This validates:
* <li> The presentation was for a credential issued by the server </li>
* <li> The credential is in its redemption window </li>
* <li> The backup-id matches a previously committed blinded backup-id and server issued receipt level </li>
* <li> The signature of the credential matches an existing publicKey associated with this backup-id </li>
*
* @param presentation A {@link BackupAuthCredentialPresentation}
* @param signature An XEd25519 signature of the presentation bytes
* @return A future that completes with the authenticated backup-id and backup-tier encoded in the presentation
*/
public CompletableFuture<AuthenticatedBackupUser> authenticateBackupUserAsync(
final BackupAuthCredentialPresentation presentation,
final byte[] signature,
final String userAgentString) {
final PresentationSignatureVerifier signatureVerifier = verifyPresentation(presentation);
return backupsDb
.retrieveAuthenticationData(presentation.getBackupId())
.thenApply(optionalAuthenticationData -> {
@@ -701,7 +724,6 @@ public class BackupManager {
* List and delete all files associated with a prefix
*
* @param prefixToDelete The prefix to expire.
* @return A stage that completes when all objects with the given prefix have been deleted
*/
private CompletableFuture<Void> deletePrefix(final String prefixToDelete, int concurrentDeletes) {
if (prefixToDelete.length() != BackupsDb.BACKUP_DIRECTORY_PATH_LENGTH

View File

@@ -47,28 +47,28 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.time.Clock;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.glassfish.jersey.server.ManagedAsync;
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.backups.BackupCredentialType;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
import org.whispersystems.textsecuregcm.backup.BackupManager;
import org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;
import org.whispersystems.textsecuregcm.backup.CopyParameters;
import org.whispersystems.textsecuregcm.backup.CopyResult;
import org.whispersystems.textsecuregcm.backup.MediaEncryptionParameters;
@@ -83,8 +83,6 @@ import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
import org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import org.whispersystems.textsecuregcm.util.Util;
import reactor.core.publisher.Mono;
@Path("/v1/archives")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Archive")
@@ -149,24 +147,20 @@ public class ArchiveController {
@ApiResponse(responseCode = "400", description = "The provided backup auth credential request was invalid")
@ApiResponse(responseCode = "403", description = "The device did not have permission to set the backup-id. Only the primary device can set the backup-id for an account")
@ApiResponse(responseCode = "429", description = "Rate limited. Too many attempts to change the backup-id have been made")
public CompletionStage<Response> setBackupId(
@ManagedAsync
public void setBackupId(
@Auth final AuthenticatedDevice authenticatedDevice,
@Valid @NotNull final SetBackupIdRequest setBackupIdRequest) throws RateLimitExceededException {
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final Device device = account.getDevice(authenticatedDevice.deviceId())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final Device device = account.getDevice(authenticatedDevice.deviceId())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return backupAuthManager
.commitBackupId(account, device,
Optional.ofNullable(setBackupIdRequest.messagesBackupAuthCredentialRequest),
Optional.ofNullable(setBackupIdRequest.mediaBackupAuthCredentialRequest))
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
});
backupAuthManager
.commitBackupId(account, device,
Optional.ofNullable(setBackupIdRequest.messagesBackupAuthCredentialRequest),
Optional.ofNullable(setBackupIdRequest.mediaBackupAuthCredentialRequest));
}
@@ -186,15 +180,13 @@ public class ArchiveController {
""")
@ApiResponse(responseCode = "200", description = "Successfully retrieved backup-id rotation limits", useReturnTypeSchema = true)
@ApiResponse(responseCode = "403", description = "Invalid account authentication")
public CompletionStage<BackupIdLimitResponse> checkLimits(@Auth final AuthenticatedDevice authenticatedDevice) {
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
@ManagedAsync
public BackupIdLimitResponse checkLimits(@Auth final AuthenticatedDevice authenticatedDevice) {
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return backupAuthManager.checkBackupIdRotationLimit(account).thenApply(limit ->
new BackupIdLimitResponse(limit.hasPermitsRemaining(), limit.nextPermitAvailable().getSeconds()));
});
final BackupAuthManager.BackupIdRotationLimit limit = backupAuthManager.checkBackupIdRotationLimit(account);
return new BackupIdLimitResponse(limit.hasPermitsRemaining(), limit.nextPermitAvailable().getSeconds());
}
public record RedeemBackupReceiptRequest(
@@ -237,18 +229,15 @@ public class ArchiveController {
@ApiResponse(responseCode = "400", description = "The provided presentation or receipt was invalid")
@ApiResponse(responseCode = "409", description = "The target account does not have a backup-id commitment")
@ApiResponse(responseCode = "429", description = "Rate limited.")
public CompletionStage<Response> redeemReceipt(
@ManagedAsync
public void redeemReceipt(
@Auth final AuthenticatedDevice authenticatedDevice,
@Valid @NotNull final RedeemBackupReceiptRequest redeemBackupReceiptRequest) {
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return backupAuthManager.redeemReceipt(account, redeemBackupReceiptRequest.receiptCredentialPresentation())
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
});
backupAuthManager.redeemReceipt(account, redeemBackupReceiptRequest.receiptCredentialPresentation());
}
public record BackupAuthCredentialsResponse(
@@ -306,14 +295,15 @@ public class ArchiveController {
@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(
@ManagedAsync
public BackupAuthCredentialsResponse getBackupZKCredentials(
@Auth AuthenticatedDevice authenticatedDevice,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@NotNull @QueryParam("redemptionStartSeconds") Long startSeconds,
@NotNull @QueryParam("redemptionEndSeconds") Long endSeconds) {
final Map<BackupCredentialType, List<BackupAuthCredentialsResponse.BackupAuthCredential>> credentialsByType =
new ConcurrentHashMap<>();
new HashMap<>();
final RedemptionRange redemptionRange;
try {
@@ -322,33 +312,26 @@ public class ArchiveController {
throw new BadRequestException(e.getMessage());
}
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return CompletableFuture.allOf(Arrays.stream(BackupCredentialType.values())
.map(credentialType -> this.backupAuthManager.getBackupAuthCredentials(
account,
credentialType,
redemptionRange)
.thenAccept(credentials -> {
backupMetrics.updateGetCredentialCounter(
UserAgentTagUtil.getPlatformTag(userAgent),
credentialType,
credentials.size());
credentialsByType.put(credentialType, credentials.stream()
.map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(
credential.credential().serialize(),
credential.redemptionTime().getEpochSecond()))
.toList());
}))
.toArray(CompletableFuture[]::new))
.thenApply(ignored -> new BackupAuthCredentialsResponse(credentialsByType.entrySet().stream()
.collect(Collectors.toMap(
e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),
Map.Entry::getValue))));
});
for (BackupCredentialType credentialType : BackupCredentialType.values()) {
final List<BackupAuthManager.Credential> credentials =
backupAuthManager.getBackupAuthCredentials(account, credentialType, redemptionRange);
backupMetrics.updateGetCredentialCounter(
UserAgentTagUtil.getPlatformTag(userAgent),
credentialType,
credentials.size());
credentialsByType.put(credentialType, credentials.stream()
.map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(
credential.credential().serialize(),
credential.redemptionTime().getEpochSecond()))
.toList());
}
return new BackupAuthCredentialsResponse(credentialsByType.entrySet().stream()
.collect(Collectors.toMap(
e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),
Map.Entry::getValue)));
}
@@ -410,7 +393,8 @@ public class ArchiveController {
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ReadAuthResponse.class)))
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<ReadAuthResponse> readAuth(
@ManagedAsync
public ReadAuthResponse readAuth(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -426,9 +410,9 @@ public class ArchiveController {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
.thenApply(user -> backupManager.generateReadAuth(user, cdn))
.thenApply(ReadAuthResponse::new);
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
return new ReadAuthResponse(backupManager.generateReadAuth(backupUser, cdn));
}
@GET
@@ -441,7 +425,8 @@ public class ArchiveController {
""")
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
@ApiResponseZkAuth
public CompletionStage<ExternalServiceCredentials> svrbAuth(
@ManagedAsync
public ExternalServiceCredentials svrbAuth(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -455,9 +440,9 @@ public class ArchiveController {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager
.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
.thenApply(backupManager::generateSvrbAuth);
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
return backupManager.generateSvrbAuth(backupUser);
}
public record BackupInfoResponse(
@@ -491,7 +476,8 @@ public class ArchiveController {
@ApiResponse(responseCode = "404", description = "No existing backups found")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<BackupInfoResponse> backupInfo(
@ManagedAsync
public BackupInfoResponse backupInfo(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -506,14 +492,15 @@ public class ArchiveController {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
.thenCompose(backupManager::backupInfo)
.thenApply(backupInfo -> new BackupInfoResponse(
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final BackupManager.BackupInfo backupInfo = backupManager.backupInfo(backupUser);
return new BackupInfoResponse(
backupInfo.cdn(),
backupInfo.backupSubdir(),
backupInfo.mediaSubdir(),
backupInfo.messageBackupKey(),
backupInfo.mediaUsedSpace().orElse(0L)));
backupInfo.mediaUsedSpace().orElse(0L));
}
public record SetPublicKeyRequest(
@@ -531,13 +518,14 @@ public class ArchiveController {
summary = "Set public key",
description = """
Permanently set the public key of an ED25519 key-pair for the backup-id. All requests that provide a anonymous
BackupAuthCredentialPresentation (including this one!) must also sign the presentation with the private key
BackupAuthCredentialPresentation (including this one!) must also sign the presentation with the private key
corresponding to the provided public key.
""")
@ApiResponseZkAuth
@ApiResponse(responseCode = "204", description = "The public key was set")
@ApiResponse(responseCode = "429", description = "Rate limited.")
public CompletionStage<Response> setPublicKey(
@ManagedAsync
public void setPublicKey(
@Auth final Optional<AuthenticatedDevice> account,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@@ -552,9 +540,7 @@ public class ArchiveController {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager
.setPublicKey(presentation.presentation, signature.signature, setPublicKeyRequest.backupIdPublicKey)
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
backupManager.setPublicKey(presentation.presentation, signature.signature, setPublicKeyRequest.backupIdPublicKey);
}
@@ -578,7 +564,8 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponse(responseCode = "413", description = "The provided uploadLength is larger than the maximum supported upload size. The maximum upload size is subject to change.")
@ApiResponseZkAuth
public CompletionStage<UploadDescriptorResponse> backup(
@ManagedAsync
public UploadDescriptorResponse backup(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -595,22 +582,25 @@ public class ArchiveController {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
.thenCompose(backupUser -> {
final boolean oversize = uploadLength
.map(length -> length > BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE)
.orElse(false);
backupMetrics.updateMessageBackupSizeDistribution(backupUser, oversize, uploadLength);
if (oversize) {
throw new ClientErrorException("exceeded maximum uploadLength", Response.Status.REQUEST_ENTITY_TOO_LARGE);
}
return backupManager.createMessageBackupUploadDescriptor(backupUser);
})
.thenApply(result -> new UploadDescriptorResponse(
result.cdn(),
result.key(),
result.headers(),
result.signedUploadLocation()));
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final boolean oversize = uploadLength
.map(length -> length > BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE)
.orElse(false);
backupMetrics.updateMessageBackupSizeDistribution(backupUser, oversize, uploadLength);
if (oversize) {
throw new ClientErrorException("exceeded maximum uploadLength", Response.Status.REQUEST_ENTITY_TOO_LARGE);
}
final BackupUploadDescriptor uploadDescriptor =
backupManager.createMessageBackupUploadDescriptor(backupUser);
return new UploadDescriptorResponse(
uploadDescriptor.cdn(),
uploadDescriptor.key(),
uploadDescriptor.headers(),
uploadDescriptor.signedUploadLocation());
}
@GET
@@ -627,7 +617,8 @@ public class ArchiveController {
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = UploadDescriptorResponse.class)))
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<UploadDescriptorResponse> uploadTemporaryAttachment(
@ManagedAsync
public UploadDescriptorResponse uploadTemporaryAttachment(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -638,17 +629,20 @@ public class ArchiveController {
@Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))
@NotNull
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature) {
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature)
throws RateLimitExceededException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
.thenCompose(backupManager::createTemporaryAttachmentUploadDescriptor)
.thenApply(result -> new UploadDescriptorResponse(
result.cdn(),
result.key(),
result.headers(),
result.signedUploadLocation()));
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final BackupUploadDescriptor uploadDescriptor =
backupManager.createTemporaryAttachmentUploadDescriptor(backupUser);
return new UploadDescriptorResponse(
uploadDescriptor.cdn(),
uploadDescriptor.key(),
uploadDescriptor.headers(),
uploadDescriptor.signedUploadLocation());
}
public record CopyMediaRequest(
@@ -715,7 +709,8 @@ public class ArchiveController {
@ApiResponse(responseCode = "410", description = "The source object was not found.")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<CopyMediaResponse> copyMedia(
@ManagedAsync
public CopyMediaResponse copyMedia(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -733,19 +728,20 @@ public class ArchiveController {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return Mono
.fromFuture(backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent))
.flatMap(backupUser -> backupManager.copyToBackup(backupUser, List.of(copyMediaRequest.toCopyParameters()))
.next()
.doOnNext(result -> backupMetrics.updateCopyCounter(result, UserAgentTagUtil.getPlatformTag(userAgent)))
.map(copyResult -> switch (copyResult.outcome()) {
case SUCCESS -> new CopyMediaResponse(copyResult.cdn());
case SOURCE_WRONG_LENGTH -> throw new BadRequestException("Invalid length");
case SOURCE_NOT_FOUND -> throw new ClientErrorException("Source object not found", Response.Status.GONE);
case OUT_OF_QUOTA ->
throw new ClientErrorException("Media quota exhausted", Response.Status.REQUEST_ENTITY_TOO_LARGE);
}))
.toFuture();
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final CopyResult copyResult =
backupManager.copyToBackup(backupUser, List.of(copyMediaRequest.toCopyParameters())).next()
.blockOptional()
.orElseThrow(() -> new IllegalStateException("Non empty copy request must return result"));
backupMetrics.updateCopyCounter(copyResult, UserAgentTagUtil.getPlatformTag(userAgent));
return switch (copyResult.outcome()) {
case SUCCESS -> new CopyMediaResponse(copyResult.cdn());
case SOURCE_WRONG_LENGTH -> throw new BadRequestException("Invalid length");
case SOURCE_NOT_FOUND -> throw new ClientErrorException("Source object not found", Response.Status.GONE);
case OUT_OF_QUOTA ->
throw new ClientErrorException("Media quota exhausted", Response.Status.REQUEST_ENTITY_TOO_LARGE);
};
}
public record CopyMediaBatchRequest(
@@ -808,13 +804,14 @@ public class ArchiveController {
be provided as a separate entry in the response.
""")
@ApiResponse(responseCode = "207", description = """
The request was processed and each operation's outcome must be inspected individually. This does NOT necessarily
The request was processed and each operation's outcome must be inspected individually. This does NOT necessarily
indicate the operation was a success.
""", content = @Content(schema = @Schema(implementation = CopyMediaBatchResponse.class)))
@ApiResponse(responseCode = "413", description = "All media capacity has been consumed. Free some space to continue.")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<Response> copyMedia(
@ManagedAsync
public Response copyMedia(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -833,13 +830,13 @@ public class ArchiveController {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
final Stream<CopyParameters> copyParams = copyMediaRequest.items().stream().map(CopyMediaRequest::toCopyParameters);
return Mono.fromFuture(backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent))
.flatMapMany(backupUser -> backupManager.copyToBackup(backupUser, copyParams.toList()))
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final List<CopyMediaBatchResponse.Entry> copyResults = backupManager.copyToBackup(backupUser, copyParams.toList())
.doOnNext(result -> backupMetrics.updateCopyCounter(result, UserAgentTagUtil.getPlatformTag(userAgent)))
.map(CopyMediaBatchResponse.Entry::fromCopyResult)
.collectList()
.map(list -> Response.status(207).entity(new CopyMediaBatchResponse(list)).build())
.toFuture();
.collectList().block();
return Response.status(207).entity(new CopyMediaBatchResponse(copyResults)).build();
}
@POST
@@ -853,7 +850,8 @@ public class ArchiveController {
@ApiResponse(responseCode = "204", description = "The backup was successfully refreshed")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<Response> refresh(
@ManagedAsync
public void refresh(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -867,10 +865,9 @@ public class ArchiveController {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager
.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
.thenCompose(backupManager::ttlRefresh)
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
backupManager.ttlRefresh(backupUser);
}
record StoredMediaObject(
@@ -920,7 +917,8 @@ public class ArchiveController {
@ApiResponse(responseCode = "400", description = "Invalid cursor or limit")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<ListResponse> listMedia(
@ManagedAsync
public ListResponse listMedia(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -940,16 +938,16 @@ public class ArchiveController {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager
.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
.thenCompose(backupUser -> backupManager.list(backupUser, cursor, limit.orElse(1000))
.thenApply(result -> new ListResponse(
result.media()
.stream().map(entry -> new StoredMediaObject(entry.cdn(), entry.key(), entry.length()))
.toList(),
backupUser.backupDir(),
backupUser.mediaDir(),
result.cursor().orElse(null))));
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final BackupManager.ListMediaResult listResult =
backupManager.list(backupUser, cursor, limit.orElse(1000));
return new ListResponse(listResult.media()
.stream().map(entry -> new StoredMediaObject(entry.cdn(), entry.key(), entry.length()))
.toList(),
backupUser.backupDir(),
backupUser.mediaDir(),
listResult.cursor().orElse(null));
}
public record DeleteMedia(@Size(min = 1, max = 1000) List<@Valid MediaToDelete> mediaToDelete) {
@@ -976,7 +974,8 @@ public class ArchiveController {
@ApiResponse(responseCode = "204", description = "The provided objects were successfully deleted or they do not exist")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<Response> deleteMedia(
@ManagedAsync
public void deleteMedia(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -997,12 +996,9 @@ public class ArchiveController {
.map(media -> new BackupManager.StorageDescriptor(media.cdn(), media.mediaId))
.toList();
return backupManager
.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
.thenCompose(authenticatedBackupUser -> backupManager
.deleteMedia(authenticatedBackupUser, toDelete)
.then().toFuture())
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
backupManager.deleteMedia(backupUser, toDelete).then().block();
}
@DELETE
@@ -1013,7 +1009,8 @@ public class ArchiveController {
@ApiResponse(responseCode = "204", description = "The backup has been successfully removed")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<Response> deleteBackup(
@ManagedAsync
public void deleteBackup(
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@@ -1027,10 +1024,10 @@ public class ArchiveController {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
return backupManager
.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
.thenCompose(backupManager::deleteEntireBackup)
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
backupManager.deleteEntireBackup(backupUser);
}
}

View File

@@ -252,8 +252,7 @@ public class DeviceCheckController {
switch (request.assertionRequest().action()) {
case BACKUP -> backupAuthManager.extendBackupVoucher(
account,
new Account.BackupVoucher(backupRedemptionLevel, clock.instant().plus(backupRedemptionDuration)))
.join();
new Account.BackupVoucher(backupRedemptionLevel, clock.instant().plus(backupRedemptionDuration)));
}
}

View File

@@ -8,9 +8,7 @@ import com.google.protobuf.ByteString;
import io.grpc.Status;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import java.util.concurrent.Flow;
import org.signal.chat.backup.CopyMediaRequest;
import org.signal.chat.backup.CopyMediaResponse;
import org.signal.chat.backup.DeleteAllRequest;
@@ -27,29 +25,30 @@ import org.signal.chat.backup.GetUploadFormRequest;
import org.signal.chat.backup.GetUploadFormResponse;
import org.signal.chat.backup.ListMediaRequest;
import org.signal.chat.backup.ListMediaResponse;
import org.signal.chat.backup.ReactorBackupsAnonymousGrpc;
import org.signal.chat.backup.RefreshRequest;
import org.signal.chat.backup.RefreshResponse;
import org.signal.chat.backup.SetPublicKeyRequest;
import org.signal.chat.backup.SetPublicKeyResponse;
import org.signal.chat.backup.SignedPresentation;
import org.signal.chat.backup.SimpleBackupsAnonymousGrpc;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.backup.BackupManager;
import org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;
import org.whispersystems.textsecuregcm.backup.CopyParameters;
import org.whispersystems.textsecuregcm.backup.MediaEncryptionParameters;
import org.whispersystems.textsecuregcm.controllers.ArchiveController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import reactor.adapter.JdkFlowAdapter;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
public class BackupsAnonymousGrpcService extends ReactorBackupsAnonymousGrpc.BackupsAnonymousImplBase {
public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.BackupsAnonymousImplBase {
private final BackupManager backupManager;
private final BackupMetrics backupMetrics;
@@ -60,87 +59,89 @@ public class BackupsAnonymousGrpcService extends ReactorBackupsAnonymousGrpc.Bac
}
@Override
public Mono<GetCdnCredentialsResponse> getCdnCredentials(final GetCdnCredentialsRequest request) {
return authenticateBackupUserMono(request.getSignedPresentation())
.map(user -> backupManager.generateReadAuth(user, request.getCdn()))
.map(credentials -> GetCdnCredentialsResponse.newBuilder().putAllHeaders(credentials).build());
public GetCdnCredentialsResponse getCdnCredentials(final GetCdnCredentialsRequest request) {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
return GetCdnCredentialsResponse.newBuilder()
.putAllHeaders(backupManager.generateReadAuth(backupUser, request.getCdn()))
.build();
}
@Override
public Mono<GetSvrBCredentialsResponse> getSvrBCredentials(final GetSvrBCredentialsRequest request) {
return authenticateBackupUserMono(request.getSignedPresentation())
.map(backupManager::generateSvrbAuth)
.map(credentials -> GetSvrBCredentialsResponse.newBuilder()
.setUsername(credentials.username())
.setPassword(credentials.password())
.build());
public GetSvrBCredentialsResponse getSvrBCredentials(final GetSvrBCredentialsRequest request) {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
final ExternalServiceCredentials credentials = backupManager.generateSvrbAuth(backupUser);
return GetSvrBCredentialsResponse.newBuilder()
.setUsername(credentials.username())
.setPassword(credentials.password())
.build();
}
@Override
public Mono<GetBackupInfoResponse> getBackupInfo(final GetBackupInfoRequest request) {
return Mono.fromFuture(() ->
authenticateBackupUser(request.getSignedPresentation()).thenCompose(backupManager::backupInfo))
.map(info -> GetBackupInfoResponse.newBuilder()
.setBackupName(info.messageBackupKey())
.setCdn(info.cdn())
.setBackupDir(info.backupSubdir())
.setMediaDir(info.mediaSubdir())
.setUsedSpace(info.mediaUsedSpace().orElse(0L))
.build());
public GetBackupInfoResponse getBackupInfo(final GetBackupInfoRequest request) {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser);
return GetBackupInfoResponse.newBuilder()
.setBackupName(info.messageBackupKey())
.setCdn(info.cdn())
.setBackupDir(info.backupSubdir())
.setMediaDir(info.mediaSubdir())
.setUsedSpace(info.mediaUsedSpace().orElse(0L))
.build();
}
@Override
public Mono<RefreshResponse> refresh(final RefreshRequest request) {
return Mono.fromFuture(() -> authenticateBackupUser(request.getSignedPresentation())
.thenCompose(backupManager::ttlRefresh))
.thenReturn(RefreshResponse.getDefaultInstance());
public RefreshResponse refresh(final RefreshRequest request) {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
backupManager.ttlRefresh(backupUser);
return RefreshResponse.getDefaultInstance();
}
@Override
public Mono<SetPublicKeyResponse> setPublicKey(final SetPublicKeyRequest request) {
public SetPublicKeyResponse setPublicKey(final SetPublicKeyRequest request) {
final ECPublicKey publicKey = deserialize(ECPublicKey::new, request.getPublicKey().toByteArray());
final BackupAuthCredentialPresentation presentation = deserialize(
BackupAuthCredentialPresentation::new,
request.getSignedPresentation().getPresentation().toByteArray());
final byte[] signature = request.getSignedPresentation().getPresentationSignature().toByteArray();
return Mono.fromFuture(() -> backupManager.setPublicKey(presentation, signature, publicKey))
.thenReturn(SetPublicKeyResponse.getDefaultInstance());
backupManager.setPublicKey(presentation, signature, publicKey);
return SetPublicKeyResponse.getDefaultInstance();
}
@Override
public Mono<GetUploadFormResponse> getUploadForm(final GetUploadFormRequest request) {
return authenticateBackupUserMono(request.getSignedPresentation())
.flatMap(backupUser -> switch (request.getUploadTypeCase()) {
case MESSAGES -> {
final long uploadLength = request.getMessages().getUploadLength();
final boolean oversize = uploadLength > BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE;
backupMetrics.updateMessageBackupSizeDistribution(backupUser, oversize, Optional.of(uploadLength));
if (oversize) {
yield Mono.error(Status.FAILED_PRECONDITION
.withDescription("Exceeds max upload length")
.asRuntimeException());
}
public GetUploadFormResponse getUploadForm(final GetUploadFormRequest request) throws RateLimitExceededException {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
final BackupUploadDescriptor uploadDescriptor = switch (request.getUploadTypeCase()) {
case MESSAGES -> {
final long uploadLength = request.getMessages().getUploadLength();
final boolean oversize = uploadLength > BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE;
backupMetrics.updateMessageBackupSizeDistribution(backupUser, oversize, Optional.of(uploadLength));
if (oversize) {
throw Status.FAILED_PRECONDITION
.withDescription("Exceeds max upload length")
.asRuntimeException();
}
yield Mono.fromFuture(backupManager.createMessageBackupUploadDescriptor(backupUser));
}
case MEDIA -> Mono.fromCompletionStage(backupManager.createTemporaryAttachmentUploadDescriptor(backupUser));
case UPLOADTYPE_NOT_SET -> Mono.error(Status.INVALID_ARGUMENT
.withDescription("Must set upload_type")
.asRuntimeException());
})
.map(uploadDescriptor -> GetUploadFormResponse.newBuilder()
.setCdn(uploadDescriptor.cdn())
.setKey(uploadDescriptor.key())
.setSignedUploadLocation(uploadDescriptor.signedUploadLocation())
.putAllHeaders(uploadDescriptor.headers())
.build());
yield backupManager.createMessageBackupUploadDescriptor(backupUser);
}
case MEDIA -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser);
case UPLOADTYPE_NOT_SET -> throw Status.INVALID_ARGUMENT
.withDescription("Must set upload_type")
.asRuntimeException();
};
return GetUploadFormResponse.newBuilder()
.setCdn(uploadDescriptor.cdn())
.setKey(uploadDescriptor.key())
.setSignedUploadLocation(uploadDescriptor.signedUploadLocation())
.putAllHeaders(uploadDescriptor.headers())
.build();
}
@Override
public Flux<CopyMediaResponse> copyMedia(final CopyMediaRequest request) {
return authenticateBackupUserMono(request.getSignedPresentation())
public Flow.Publisher<CopyMediaResponse> copyMedia(final CopyMediaRequest request) {
final Flux<CopyMediaResponse> flux = Mono
.fromFuture(() -> authenticateBackupUserAsync(request.getSignedPresentation()))
.flatMapMany(backupUser -> backupManager.copyToBackup(backupUser,
request.getItemsList().stream().map(item -> new CopyParameters(
item.getSourceAttachmentCdn(), item.getSourceKey(),
@@ -167,46 +168,43 @@ public class BackupsAnonymousGrpcService extends ReactorBackupsAnonymousGrpc.Bac
};
return builder.build();
});
return JdkFlowAdapter.publisherToFlowPublisher(flux);
}
@Override
public Mono<ListMediaResponse> listMedia(final ListMediaRequest request) {
return authenticateBackupUserMono(request.getSignedPresentation()).zipWhen(
backupUser -> Mono.fromFuture(backupManager.list(
backupUser,
request.hasCursor() ? Optional.of(request.getCursor()) : Optional.empty(),
request.getLimit()).toCompletableFuture()),
(backupUser, listResult) -> {
final ListMediaResponse.Builder builder = ListMediaResponse.newBuilder();
for (BackupManager.StorageDescriptorWithLength sd : listResult.media()) {
builder.addPage(ListMediaResponse.ListEntry.newBuilder()
.setMediaId(ByteString.copyFrom(sd.key()))
.setCdn(sd.cdn())
.setLength(sd.length())
.build());
}
builder
.setBackupDir(backupUser.backupDir())
.setMediaDir(backupUser.mediaDir());
listResult.cursor().ifPresent(builder::setCursor);
return builder.build();
});
public ListMediaResponse listMedia(final ListMediaRequest request) {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
final BackupManager.ListMediaResult listResult = backupManager.list(
backupUser,
request.hasCursor() ? Optional.of(request.getCursor()) : Optional.empty(),
request.getLimit());
final ListMediaResponse.Builder builder = ListMediaResponse.newBuilder();
for (BackupManager.StorageDescriptorWithLength sd : listResult.media()) {
builder.addPage(ListMediaResponse.ListEntry.newBuilder()
.setMediaId(ByteString.copyFrom(sd.key()))
.setCdn(sd.cdn())
.setLength(sd.length())
.build());
}
builder
.setBackupDir(backupUser.backupDir())
.setMediaDir(backupUser.mediaDir());
listResult.cursor().ifPresent(builder::setCursor);
return builder.build();
}
@Override
public Mono<DeleteAllResponse> deleteAll(final DeleteAllRequest request) {
return Mono.fromFuture(() -> authenticateBackupUser(request.getSignedPresentation())
.thenCompose(backupManager::deleteEntireBackup))
.thenReturn(DeleteAllResponse.getDefaultInstance());
public DeleteAllResponse deleteAll(final DeleteAllRequest request) {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
backupManager.deleteEntireBackup(backupUser);
return DeleteAllResponse.getDefaultInstance();
}
@Override
public Flux<DeleteMediaResponse> deleteMedia(final DeleteMediaRequest request) {
return Mono
.fromFuture(() -> authenticateBackupUser(request.getSignedPresentation()))
public Flow.Publisher<DeleteMediaResponse> deleteMedia(final DeleteMediaRequest request) {
return JdkFlowAdapter.publisherToFlowPublisher(Mono
.fromFuture(() -> authenticateBackupUserAsync(request.getSignedPresentation()))
.flatMapMany(backupUser -> backupManager.deleteMedia(backupUser, request
.getItemsList()
.stream()
@@ -214,20 +212,15 @@ public class BackupsAnonymousGrpcService extends ReactorBackupsAnonymousGrpc.Bac
.toList()))
.map(storageDescriptor -> DeleteMediaResponse.newBuilder()
.setMediaId(ByteString.copyFrom(storageDescriptor.key()))
.setCdn(storageDescriptor.cdn()).build());
.setCdn(storageDescriptor.cdn()).build()));
}
private Mono<AuthenticatedBackupUser> authenticateBackupUserMono(final SignedPresentation signedPresentation) {
return Mono.fromFuture(() -> authenticateBackupUser(signedPresentation));
}
private CompletableFuture<AuthenticatedBackupUser> authenticateBackupUser(
final SignedPresentation signedPresentation) {
private CompletableFuture<AuthenticatedBackupUser> authenticateBackupUserAsync(final SignedPresentation signedPresentation) {
if (signedPresentation == null) {
throw Status.UNAUTHENTICATED.asRuntimeException();
}
try {
return backupManager.authenticateBackupUser(
return backupManager.authenticateBackupUserAsync(
new BackupAuthCredentialPresentation(signedPresentation.getPresentation().toByteArray()),
signedPresentation.getPresentationSignature().toByteArray(),
RequestAttributesUtil.getUserAgent().orElse(null));
@@ -236,6 +229,10 @@ public class BackupsAnonymousGrpcService extends ReactorBackupsAnonymousGrpc.Bac
}
}
private AuthenticatedBackupUser authenticateBackupUser(final SignedPresentation signedPresentation) {
return authenticateBackupUserAsync(signedPresentation).join();
}
/**
* Convert an int from a proto uint32 to a signed positive integer, throwing if the value exceeds
* {@link Integer#MAX_VALUE}. To convert to a long, see {@link Integer#toUnsignedLong(int)}

View File

@@ -14,11 +14,11 @@ import java.util.Optional;
import java.util.stream.Collectors;
import org.signal.chat.backup.GetBackupAuthCredentialsRequest;
import org.signal.chat.backup.GetBackupAuthCredentialsResponse;
import org.signal.chat.backup.ReactorBackupsGrpc;
import org.signal.chat.backup.RedeemReceiptRequest;
import org.signal.chat.backup.RedeemReceiptResponse;
import org.signal.chat.backup.SetBackupIdRequest;
import org.signal.chat.backup.SetBackupIdResponse;
import org.signal.chat.backup.SimpleBackupsGrpc;
import org.signal.chat.common.ZkCredential;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
@@ -28,14 +28,14 @@ import org.whispersystems.textsecuregcm.auth.RedemptionRange;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import reactor.core.publisher.Mono;
public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
public class BackupsGrpcService extends SimpleBackupsGrpc.BackupsImplBase {
private final AccountsManager accountManager;
private final BackupAuthManager backupAuthManager;
@@ -48,7 +48,7 @@ public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
}
@Override
public Mono<SetBackupIdResponse> setBackupId(SetBackupIdRequest request) {
public SetBackupIdResponse setBackupId(SetBackupIdRequest request) throws RateLimitExceededException {
final Optional<BackupAuthCredentialRequest> messagesCredentialRequest = deserializeWithEmptyPresenceCheck(
BackupAuthCredentialRequest::new,
@@ -59,28 +59,25 @@ public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
request.getMediaBackupAuthCredentialRequest());
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return authenticatedAccount()
.flatMap(account -> {
final Device device = account
.getDevice(authenticatedDevice.deviceId())
.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException);
return Mono.fromFuture(
backupAuthManager.commitBackupId(account, device, messagesCredentialRequest, mediaCredentialRequest));
})
.thenReturn(SetBackupIdResponse.getDefaultInstance());
final Account account = authenticatedAccount();
final Device device = account
.getDevice(authenticatedDevice.deviceId())
.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException);
backupAuthManager.commitBackupId(account, device, messagesCredentialRequest, mediaCredentialRequest);
return SetBackupIdResponse.getDefaultInstance();
}
public Mono<RedeemReceiptResponse> redeemReceipt(RedeemReceiptRequest request) {
public RedeemReceiptResponse redeemReceipt(RedeemReceiptRequest request) {
final ReceiptCredentialPresentation receiptCredentialPresentation = deserialize(
ReceiptCredentialPresentation::new,
request.getPresentation().toByteArray());
return authenticatedAccount()
.flatMap(account -> Mono.fromFuture(backupAuthManager.redeemReceipt(account, receiptCredentialPresentation)))
.thenReturn(RedeemReceiptResponse.getDefaultInstance());
final Account account = authenticatedAccount();
backupAuthManager.redeemReceipt(account, receiptCredentialPresentation);
return RedeemReceiptResponse.getDefaultInstance();
}
@Override
public Mono<GetBackupAuthCredentialsResponse> getBackupAuthCredentials(GetBackupAuthCredentialsRequest request) {
public GetBackupAuthCredentialsResponse getBackupAuthCredentials(GetBackupAuthCredentialsRequest request) {
final Tag platformTag = UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null));
final RedemptionRange redemptionRange;
try {
@@ -90,46 +87,41 @@ public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
} catch (IllegalArgumentException e) {
throw Status.INVALID_ARGUMENT.withDescription(e.getMessage()).asRuntimeException();
}
return authenticatedAccount().flatMap(account -> {
final Mono<List<BackupAuthManager.Credential>> messageCredentials = Mono.fromCompletionStage(() ->
backupAuthManager.getBackupAuthCredentials(
account,
BackupCredentialType.MESSAGES,
redemptionRange))
.doOnSuccess(credentials ->
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MESSAGES, credentials.size()));
final Account account = authenticatedAccount();
final List<BackupAuthManager.Credential> messageCredentials =
backupAuthManager.getBackupAuthCredentials(
account,
BackupCredentialType.MESSAGES,
redemptionRange);
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MESSAGES, messageCredentials.size());
final Mono<List<BackupAuthManager.Credential>> mediaCredentials = Mono.fromCompletionStage(() ->
backupAuthManager.getBackupAuthCredentials(
account,
BackupCredentialType.MEDIA,
redemptionRange))
.doOnSuccess(credentials ->
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MEDIA, credentials.size()));
final List<BackupAuthManager.Credential> mediaCredentials =
backupAuthManager.getBackupAuthCredentials(
account,
BackupCredentialType.MEDIA,
redemptionRange);
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MEDIA, mediaCredentials.size());
return messageCredentials.zipWith(mediaCredentials, (messageCreds, mediaCreds) ->
GetBackupAuthCredentialsResponse.newBuilder()
.putAllMessageCredentials(messageCreds.stream().collect(Collectors.toMap(
c -> c.redemptionTime().getEpochSecond(),
c -> ZkCredential.newBuilder()
.setCredential(ByteString.copyFrom(c.credential().serialize()))
.setRedemptionTime(c.redemptionTime().getEpochSecond())
.build())))
.putAllMediaCredentials(mediaCreds.stream().collect(Collectors.toMap(
c -> c.redemptionTime().getEpochSecond(),
c -> ZkCredential.newBuilder()
.setCredential(ByteString.copyFrom(c.credential().serialize()))
.setRedemptionTime(c.redemptionTime().getEpochSecond())
.build())))
.build());
});
return GetBackupAuthCredentialsResponse.newBuilder()
.putAllMessageCredentials(messageCredentials.stream().collect(Collectors.toMap(
c -> c.redemptionTime().getEpochSecond(),
c -> ZkCredential.newBuilder()
.setCredential(ByteString.copyFrom(c.credential().serialize()))
.setRedemptionTime(c.redemptionTime().getEpochSecond())
.build())))
.putAllMediaCredentials(mediaCredentials.stream().collect(Collectors.toMap(
c -> c.redemptionTime().getEpochSecond(),
c -> ZkCredential.newBuilder()
.setCredential(ByteString.copyFrom(c.credential().serialize()))
.setRedemptionTime(c.redemptionTime().getEpochSecond())
.build())))
.build();
}
private Mono<Account> authenticatedAccount() {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return Mono
.fromFuture(() -> accountManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException));
private Account authenticatedAccount() {
return accountManager
.getByAccountIdentifier(AuthenticationUtil.requireAuthenticatedDevice().accountIdentifier())
.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException);
}
private interface Deserializer<T> {

View File

@@ -14,6 +14,7 @@ import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import java.io.IOException;
import java.io.UncheckedIOException;
@@ -46,7 +47,9 @@ public class ErrorMappingInterceptor implements ServerInterceptor {
return;
}
final StatusRuntimeException statusException = switch (status.getCause()) {
final Throwable cause = ExceptionUtils.unwrap(status.getCause());
final StatusRuntimeException statusException = switch (cause) {
case ConvertibleToGrpcStatus e -> e.toStatusRuntimeException();
case UncheckedIOException e -> {
log.warn("RPC {} encountered UncheckedIOException", call.getMethodDescriptor().getFullMethodName(), e.getCause());

View File

@@ -2,6 +2,7 @@ package org.whispersystems.textsecuregcm.util;
import java.util.concurrent.CompletionException;
import java.util.function.Function;
import java.util.function.Supplier;
public final class ExceptionUtils {
@@ -81,4 +82,49 @@ public final class ExceptionUtils {
throw wrap(fn.apply(e));
});
}
/**
* Runs the supplier, throwing a checked exception if the supplier throws an exception that unwraps to the provided type
*
* @param exType The exception type to check for
* @param supplier A supplier that produces a T
* @return The result of the supplier
* @param <T> The supplier type
* @param <E> The checked exception type
* @throws E If the supplier throws E or a type that {@link #unwrap}s to E
*/
public static <T, E extends Throwable> T unwrapSupply(Class<E> exType, Supplier<T> supplier) throws E {
try {
return supplier.get();
} catch (RuntimeException e) {
final Throwable ex = unwrap(e);
if (exType.isInstance(ex)) {
throw exType.cast(ex);
}
throw e;
}
}
/**
* Runs the supplier, throwing a checked exception if the supplier throws an exception that unwraps to the provided type
*
* @param exType The exception type to check for
* @param supplier A supplier that produces a T
* @param marshal A function that maps from the thrown type to another exception type
* @return The result of the supplier
* @param <T> The supplier type
* @param <E> The checked exception type that may be thrown from supplier
* @throws F If the supplier throws E or a type that {@link #unwrap}s to E
*/
public static <T, E extends Throwable, F extends Throwable> T unwrapSupply(Class<E> exType, Supplier<T> supplier, Function<E, F> marshal) throws F {
try {
return supplier.get();
} catch (RuntimeException e) {
final Throwable ex = unwrap(e);
if (exType.isInstance(ex)) {
throw marshal.apply(exType.cast(ex));
}
throw e;
}
}
}

View File

@@ -12,6 +12,7 @@ import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
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;
@@ -64,7 +65,6 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
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;
@@ -107,19 +107,18 @@ public class BackupAuthManagerTest {
}
@Test
void commitBackupId() {
void commitBackupId() throws RateLimitExceededException {
final BackupAuthManager authManager = create();
final Account account = mock(Account.class);
when(account.getUuid()).thenReturn(aci);
when(accountsManager.updateAsync(any(), any()))
when(accountsManager.update(any(), any()))
.thenAnswer(invocation -> {
final Account a = invocation.getArgument(0);
final Consumer<Account> updater = invocation.getArgument(1);
updater.accept(a);
return CompletableFuture.completedFuture(a);
return a;
});
final BackupAuthCredentialRequest messagesCredentialRequest = backupAuthTestUtil.getRequest(messagesBackupKey, aci);
@@ -127,7 +126,7 @@ public class BackupAuthManagerTest {
authManager.commitBackupId(account, primaryDevice(),
Optional.of(messagesCredentialRequest),
Optional.of(mediaCredentialRequest)).join();
Optional.of(mediaCredentialRequest));
verify(account).setBackupCredentialRequests(messagesCredentialRequest.serialize(),
mediaCredentialRequest.serialize());
@@ -138,13 +137,13 @@ public class BackupAuthManagerTest {
void commitOnAnyBackupLevel(final BackupLevel backupLevel) {
final BackupAuthManager authManager = create();
final Account account = new MockAccountBuilder().backupLevel(backupLevel).build();
when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account));
when(accountsManager.update(any(), any())).thenReturn(account);
final ThrowableAssert.ThrowingCallable commit = () ->
authManager.commitBackupId(account,
primaryDevice(),
Optional.of(backupAuthTestUtil.getRequest(messagesBackupKey, aci)),
Optional.of(backupAuthTestUtil.getRequest(mediaBackupKey, aci))).join();
Optional.of(backupAuthTestUtil.getRequest(mediaBackupKey, aci)));
Assertions.assertThatNoException().isThrownBy(commit);
}
@@ -152,13 +151,13 @@ public class BackupAuthManagerTest {
void commitRequiresPrimary() {
final BackupAuthManager authManager = create();
final Account account = new MockAccountBuilder().build();
when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account));
when(accountsManager.update(any(), any())).thenReturn(account);
final ThrowableAssert.ThrowingCallable commit = () ->
authManager.commitBackupId(account,
linkedDevice(),
Optional.of(backupAuthTestUtil.getRequest(messagesBackupKey, aci)),
Optional.of(backupAuthTestUtil.getRequest(mediaBackupKey, aci))).join();
Optional.of(backupAuthTestUtil.getRequest(mediaBackupKey, aci)));
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(commit)
.extracting(ex -> ex.getStatus().getCode())
@@ -186,7 +185,7 @@ public class BackupAuthManagerTest {
final RedemptionRange range = range(Duration.ofDays(1));
final List<BackupAuthManager.Credential> creds =
authManager.getBackupAuthCredentials(account, credentialType, range(Duration.ofDays(1))).join();
authManager.getBackupAuthCredentials(account, credentialType, range(Duration.ofDays(1)));
assertThat(creds).hasSize(2);
assertThat(requestContext
@@ -207,7 +206,7 @@ public class BackupAuthManagerTest {
.mediaCredential(backupAuthTestUtil.getRequest(mediaBackupKey, aci))
.build();
assertThat(authManager.getBackupAuthCredentials(account, credentialType, range(Duration.ofDays(1))).join())
assertThat(authManager.getBackupAuthCredentials(account, credentialType, range(Duration.ofDays(1))))
.hasSize(2);
}
@@ -220,7 +219,7 @@ public class BackupAuthManagerTest {
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() ->
authManager.getBackupAuthCredentials(account, credentialType, range(Duration.ofDays(1))).join())
authManager.getBackupAuthCredentials(account, credentialType, range(Duration.ofDays(1))))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.NOT_FOUND);
}
@@ -245,7 +244,7 @@ public class BackupAuthManagerTest {
.build();
final List<BackupAuthManager.Credential> creds = authManager.getBackupAuthCredentials(account,
credentialType, range(Duration.ofDays(7))).join();
credentialType, range(Duration.ofDays(7)));
assertThat(creds).hasSize(8);
Instant redemptionTime = clock.instant().truncatedTo(ChronoUnit.DAYS);
@@ -275,7 +274,7 @@ public class BackupAuthManagerTest {
final List<BackupAuthManager.Credential> creds = authManager.getBackupAuthCredentials(
account,
BackupCredentialType.MESSAGES,
range(RedemptionRange.MAX_REDEMPTION_DURATION)).join();
range(RedemptionRange.MAX_REDEMPTION_DURATION));
Instant redemptionTime = Instant.EPOCH;
final BackupAuthCredentialRequestContext requestContext = BackupAuthCredentialRequestContext.create(
messagesBackupKey, aci);
@@ -311,15 +310,15 @@ public class BackupAuthManagerTest {
.backupVoucher(null)
.build();
when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(updated));
when(accountsManager.update(any(), any())).thenReturn(updated);
clock.pin(day2.plus(Duration.ofSeconds(1)));
assertThat(authManager.getBackupAuthCredentials(account, BackupCredentialType.MESSAGES, range(Duration.ofDays(7))).join())
assertThat(authManager.getBackupAuthCredentials(account, BackupCredentialType.MESSAGES, range(Duration.ofDays(7))))
.hasSize(8);
@SuppressWarnings("unchecked") final ArgumentCaptor<Consumer<Account>> accountUpdater = ArgumentCaptor.forClass(
Consumer.class);
verify(accountsManager, times(1)).updateAsync(any(), accountUpdater.capture());
verify(accountsManager, times(1)).update(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);
@@ -343,11 +342,11 @@ public class BackupAuthManagerTest {
.mediaCredential(Optional.of(new byte[0]))
.build();
clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));
when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account));
when(accountsManager.update(any(), any())).thenReturn(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());
authManager.redeemReceipt(account, receiptPresentation(201, expirationTime));
verify(accountsManager, times(1)).update(any(), any());
}
@Test
@@ -361,7 +360,7 @@ public class BackupAuthManagerTest {
.thenReturn(CompletableFuture.completedFuture(true));
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() ->
authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)).join())
authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.ABORTED);
}
@@ -379,13 +378,13 @@ public class BackupAuthManagerTest {
.build();
clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));
when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account));
when(accountsManager.update(any(), any())).thenReturn(account);
when(redeemedReceiptsManager.put(any(), eq(newExpirationTime.getEpochSecond()), eq(201L), eq(aci)))
.thenReturn(CompletableFuture.completedFuture(true));
authManager.redeemReceipt(account, receiptPresentation(201, newExpirationTime)).join();
authManager.redeemReceipt(account, receiptPresentation(201, newExpirationTime));
final ArgumentCaptor<Consumer<Account>> updaterCaptor = ArgumentCaptor.captor();
verify(accountsManager, times(1)).updateAsync(any(), updaterCaptor.capture());
verify(accountsManager, times(1)).update(any(), updaterCaptor.capture());
updaterCaptor.getValue().accept(account);
// Should select the voucher with the later expiration time
@@ -398,7 +397,7 @@ public class BackupAuthManagerTest {
clock.pin(expirationTime.plus(Duration.ofSeconds(1)));
final BackupAuthManager authManager = create();
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), receiptPresentation(3, expirationTime)).join())
.isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), receiptPresentation(3, expirationTime)))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
verifyNoInteractions(accountsManager);
@@ -413,7 +412,7 @@ public class BackupAuthManagerTest {
final BackupAuthManager authManager = create();
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() ->
authManager.redeemReceipt(mock(Account.class), receiptPresentation(level, expirationTime)).join())
authManager.redeemReceipt(mock(Account.class), receiptPresentation(level, expirationTime)))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
verifyNoInteractions(accountsManager);
@@ -425,7 +424,7 @@ public class BackupAuthManagerTest {
final BackupAuthManager authManager = create();
final ReceiptCredentialPresentation invalid = receiptPresentation(ServerSecretParams.generate(), 3L, Instant.EPOCH);
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), invalid).join())
.isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), invalid))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
verifyNoInteractions(accountsManager);
@@ -433,7 +432,7 @@ public class BackupAuthManagerTest {
}
@Test
void receiptAlreadyRedeemed() throws InvalidInputException, VerificationFailedException {
void receiptAlreadyRedeemed() {
final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1));
final BackupAuthManager authManager = create();
final Account account = new MockAccountBuilder()
@@ -441,12 +440,12 @@ public class BackupAuthManagerTest {
.build();
clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));
when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account));
when(accountsManager.update(any(), any())).thenReturn(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))
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
verifyNoInteractions(accountsManager);
@@ -484,8 +483,7 @@ public class BackupAuthManagerTest {
? new Account.BackupVoucher(1, Instant.EPOCH.plus(Duration.ofSeconds(1)))
: null)
.build();
final BackupAuthManager.BackupIdRotationLimit limit = authManager.checkBackupIdRotationLimit(account)
.toCompletableFuture().join();
final BackupAuthManager.BackupIdRotationLimit limit = authManager.checkBackupIdRotationLimit(account);
final boolean expectHasPermits = !messageLimited && (!mediaLimited || !hasVoucher);
final Duration expectedDuration = expectHasPermits ? Duration.ZERO : Duration.ofDays(1);
assertThat(limit.hasPermitsRemaining()).isEqualTo(expectHasPermits);
@@ -524,7 +522,7 @@ public class BackupAuthManagerTest {
.backupVoucher(backupVoucher)
.build();
when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account));
when(accountsManager.update(any(), any())).thenReturn(account);
final Optional<BackupAuthCredentialRequest> newMessagesCredential = switch (messageChange) {
case MATCH -> Optional.of(storedMessagesCredential);
@@ -543,7 +541,7 @@ public class BackupAuthManagerTest {
final boolean expectRateLimit = ((mediaChange == CredentialChangeType.MISMATCH) && rateLimitMediaBackupId && paid)
|| ((messageChange == CredentialChangeType.MISMATCH) && rateLimitMessagesBackupId);
final ThrowableAssert.ThrowingCallable commit = () ->
authManager.commitBackupId(account, primaryDevice(), newMessagesCredential, newMediaCredential).join();
authManager.commitBackupId(account, primaryDevice(), newMessagesCredential, newMediaCredential);
if (messageChange == CredentialChangeType.NO_UPDATE && mediaChange == CredentialChangeType.NO_UPDATE) {
assertThatExceptionOfType(StatusRuntimeException.class)
@@ -551,7 +549,7 @@ public class BackupAuthManagerTest {
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
} else if (expectRateLimit) {
assertThatException().isThrownBy(commit).withRootCauseInstanceOf(RateLimitExceededException.class);
assertThatExceptionOfType(RateLimitExceededException.class).isThrownBy(commit);
} else {
assertThatNoException().isThrownBy(commit);
}
@@ -611,26 +609,29 @@ public class BackupAuthManagerTest {
}
private static RateLimiters rateLimiter(final UUID aci, boolean rateLimitBackupId,
boolean rateLimitPaidMediaBackupId) {
final RateLimiters limiters = mock(RateLimiters.class);
private static RateLimiters rateLimiter(final UUID aci, boolean rateLimitBackupId, boolean rateLimitPaidMediaBackupId) {
try {
final RateLimiters limiters = mock(RateLimiters.class);
final RateLimiter allowLimiter = mock(RateLimiter.class);
when(allowLimiter.hasAvailablePermitsAsync(eq(aci), anyLong())).thenReturn(CompletableFuture.completedFuture(true));
when(allowLimiter.validateAsync(aci)).thenReturn(CompletableFuture.completedFuture(null));
when(allowLimiter.config()).thenReturn(new RateLimiterConfig(1, Duration.ofDays(1), false));
final RateLimiter allowLimiter = mock(RateLimiter.class);
when(allowLimiter.hasAvailablePermitsAsync(eq(aci), anyLong())).thenReturn(
CompletableFuture.completedFuture(true));
when(allowLimiter.config()).thenReturn(new RateLimiterConfig(1, Duration.ofDays(1), false));
final RateLimiter denyLimiter = mock(RateLimiter.class);
when(denyLimiter.hasAvailablePermitsAsync(eq(aci), anyLong())).thenReturn(CompletableFuture.completedFuture(false));
when(denyLimiter.validateAsync(aci))
.thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null)));
when(denyLimiter.config()).thenReturn(new RateLimiterConfig(1, Duration.ofDays(1), false));
final RateLimiter denyLimiter = mock(RateLimiter.class);
when(denyLimiter.hasAvailablePermitsAsync(eq(aci), anyLong())).thenReturn(
CompletableFuture.completedFuture(false));
doThrow(new RateLimitExceededException(null)).when(denyLimiter).validate(aci);
when(denyLimiter.config()).thenReturn(new RateLimiterConfig(1, Duration.ofDays(1), false));
when(limiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID))
.thenReturn(rateLimitBackupId ? denyLimiter : allowLimiter);
when(limiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID))
.thenReturn(rateLimitPaidMediaBackupId ? denyLimiter : allowLimiter);
return limiters;
when(limiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID))
.thenReturn(rateLimitBackupId ? denyLimiter : allowLimiter);
when(limiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID))
.thenReturn(rateLimitPaidMediaBackupId ? denyLimiter : allowLimiter);
return limiters;
} catch (RateLimitExceededException e) {
throw new RuntimeException(e);
}
}
private RedemptionRange range(Duration length) {

View File

@@ -76,6 +76,6 @@ public class BackupAuthTestUtil {
});
final RedemptionRange redemptionRange;
redemptionRange = RedemptionRange.inclusive(clock, redemptionStart, redemptionEnd);
return issuer.getBackupAuthCredentials(account, credentialType, redemptionRange).join();
return issuer.getBackupAuthCredentials(account, credentialType, redemptionRange);
}
}

View File

@@ -13,6 +13,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
@@ -230,11 +231,11 @@ public class BackupManagerTest {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, backupLevel);
backupManager.createMessageBackupUploadDescriptor(backupUser).join();
backupManager.createMessageBackupUploadDescriptor(backupUser);
verify(tusCredentialGenerator, times(1))
.generateUpload("%s/%s".formatted(backupUser.backupDir(), BackupManager.MESSAGE_BACKUP_NAME));
final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser).join();
final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser);
assertThat(info.backupSubdir()).isEqualTo(backupUser.backupDir()).isNotBlank();
assertThat(info.messageBackupKey()).isEqualTo(BackupManager.MESSAGE_BACKUP_NAME);
assertThat(info.mediaUsedSpace()).isEqualTo(Optional.empty());
@@ -253,18 +254,17 @@ public class BackupManagerTest {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, backupLevel);
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.createMessageBackupUploadDescriptor(backupUser).join())
.isThrownBy(() -> backupManager.createMessageBackupUploadDescriptor(backupUser))
.matches(exception -> exception.getStatus().getCode() == Status.UNAUTHENTICATED.getCode());
}
@Test
public void createTemporaryMediaAttachmentRateLimited() {
public void createTemporaryMediaAttachmentRateLimited() throws RateLimitExceededException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
when(mediaUploadLimiter.validateAsync(eq(BackupManager.rateLimitKey(backupUser))))
.thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null)));
CompletableFutureTestUtil.assertFailsWithCause(
RateLimitExceededException.class,
backupManager.createTemporaryAttachmentUploadDescriptor(backupUser).toCompletableFuture());
doThrow(new RateLimitExceededException(null))
.when(mediaUploadLimiter).validate(eq(BackupManager.rateLimitKey(backupUser)));
assertThatExceptionOfType(RateLimitExceededException.class)
.isThrownBy(() -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser));
}
@Test
@@ -297,11 +297,11 @@ public class BackupManagerTest {
// create backup at t=tstart
testClock.pin(tstart);
backupManager.createMessageBackupUploadDescriptor(backupUser).join();
backupManager.createMessageBackupUploadDescriptor(backupUser);
// refresh at t=tnext
testClock.pin(tnext);
backupManager.ttlRefresh(backupUser).join();
backupManager.ttlRefresh(backupUser);
checkExpectedExpirations(
tnext.truncatedTo(ChronoUnit.DAYS),
@@ -319,11 +319,11 @@ public class BackupManagerTest {
// create backup at t=tstart
testClock.pin(tstart);
backupManager.createMessageBackupUploadDescriptor(backupUser).join();
backupManager.createMessageBackupUploadDescriptor(backupUser);
// create again at t=tnext
testClock.pin(tnext);
backupManager.createMessageBackupUploadDescriptor(backupUser).join();
backupManager.createMessageBackupUploadDescriptor(backupUser);
checkExpectedExpirations(
tnext.truncatedTo(ChronoUnit.DAYS),
@@ -363,7 +363,7 @@ public class BackupManagerTest {
backupManager.setPublicKey(
presentation,
keyPair.getPrivateKey().calculateSignature(presentation.serialize()),
keyPair.getPublicKey()).join();
keyPair.getPublicKey());
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(
@@ -384,10 +384,10 @@ public class BackupManagerTest {
final byte[] signature = keyPair.getPrivateKey().calculateSignature(presentation.serialize());
// haven't set a public key yet
assertThat(CompletableFutureTestUtil.assertFailsWithCause(
StatusRuntimeException.class,
backupManager.authenticateBackupUser(presentation, signature, null))
.getStatus().getCode())
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(presentation, signature, null))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.UNAUTHENTICATED.getCode());
}
@@ -401,17 +401,17 @@ public class BackupManagerTest {
final byte[] signature1 = keyPair1.getPrivateKey().calculateSignature(presentation.serialize());
final byte[] signature2 = keyPair2.getPrivateKey().calculateSignature(presentation.serialize());
backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey()).join();
backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey());
// shouldn't be able to set a different public key
assertThat(CompletableFutureTestUtil.assertFailsWithCause(
StatusRuntimeException.class,
assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() ->
backupManager.setPublicKey(presentation, signature2, keyPair2.getPublicKey()))
.getStatus().getCode())
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.UNAUTHENTICATED.getCode());
// should be able to set the same public key again (noop)
backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey()).join();
backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey());
}
@Test
@@ -432,17 +432,17 @@ public class BackupManagerTest {
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.UNAUTHENTICATED.getCode());
backupManager.setPublicKey(presentation, signature, keyPair.getPublicKey()).join();
backupManager.setPublicKey(presentation, signature, keyPair.getPublicKey());
// shouldn't be able to authenticate with an invalid signature
assertThat(CompletableFutureTestUtil.assertFailsWithCause(
StatusRuntimeException.class,
backupManager.authenticateBackupUser(presentation, wrongSignature, null))
.getStatus().getCode())
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(presentation, wrongSignature, null))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.UNAUTHENTICATED.getCode());
// correct signature
final AuthenticatedBackupUser user = backupManager.authenticateBackupUser(presentation, signature, null).join();
final AuthenticatedBackupUser user = backupManager.authenticateBackupUser(presentation, signature, null);
assertThat(user.backupId()).isEqualTo(presentation.getBackupId());
assertThat(user.backupLevel()).isEqualTo(BackupLevel.FREE);
}
@@ -456,15 +456,15 @@ public class BackupManagerTest {
backupKey, aci);
final ECKeyPair keyPair = ECKeyPair.generate();
final byte[] signature = keyPair.getPrivateKey().calculateSignature(oldCredential.serialize());
backupManager.setPublicKey(oldCredential, signature, keyPair.getPublicKey()).join();
backupManager.setPublicKey(oldCredential, signature, keyPair.getPublicKey());
// should be accepted the day before to forgive clock skew
testClock.pin(Instant.ofEpochSecond(1));
assertThatNoException().isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature, null).join());
assertThatNoException().isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature, null));
// should be accepted the day after to forgive clock skew
testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(2)));
assertThatNoException().isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature, null).join());
assertThatNoException().isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature, null));
// should be rejected the day after that
testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(3)));
@@ -713,8 +713,7 @@ public class BackupManagerTest {
Optional.of("newCursor")
)));
final BackupManager.ListMediaResult result = backupManager.list(backupUser, cursor, 17)
.toCompletableFuture().join();
final BackupManager.ListMediaResult result = backupManager.list(backupUser, cursor, 17);
assertThat(result.media()).hasSize(1);
assertThat(result.media().getFirst().cdn()).isEqualTo(13);
assertThat(result.media().getFirst().key()).isEqualTo(
@@ -733,7 +732,7 @@ public class BackupManagerTest {
when(svrbClient.removeData(anyString())).thenReturn(CompletableFuture.completedFuture(null));
// Deleting should swap the backupDir for the user
backupManager.deleteEntireBackup(original).join();
backupManager.deleteEntireBackup(original);
verifyNoInteractions(remoteStorageManager);
verify(svrbClient).removeData(HexFormat.of().formatHex(BackupsDb.hashedBackupId(original.backupId())));
@@ -747,7 +746,7 @@ public class BackupManagerTest {
Collections.emptyList(),
Optional.empty()
)));
backupManager.deleteEntireBackup(after).join();
backupManager.deleteEntireBackup(after);
verify(remoteStorageManager, times(1))
.list(eq(after.backupDir() + "/"), eq(Optional.empty()), anyLong());
@@ -914,7 +913,7 @@ public class BackupManagerTest {
.toList();
for (int i = 0; i < backupUsers.size(); i++) {
testClock.pin(days(i));
backupManager.createMessageBackupUploadDescriptor(backupUsers.get(i)).join();
backupManager.createMessageBackupUploadDescriptor(backupUsers.get(i));
}
// set of backup-id hashes that should be expired (initially t=0)
@@ -949,11 +948,11 @@ public class BackupManagerTest {
// refreshed media timestamp at t=5
testClock.pin(days(5));
backupManager.createMessageBackupUploadDescriptor(backupUser(backupId, BackupCredentialType.MESSAGES, BackupLevel.PAID)).join();
backupManager.createMessageBackupUploadDescriptor(backupUser(backupId, BackupCredentialType.MESSAGES, BackupLevel.PAID));
// refreshed messages timestamp at t=6
testClock.pin(days(6));
backupManager.createMessageBackupUploadDescriptor(backupUser(backupId, BackupCredentialType.MESSAGES, BackupLevel.FREE)).join();
backupManager.createMessageBackupUploadDescriptor(backupUser(backupId, BackupCredentialType.MESSAGES, BackupLevel.FREE));
Function<Instant, List<ExpiredBackup>> getExpired = time -> backupManager
.getExpiredBackups(1, Schedulers.immediate(), time)
@@ -974,7 +973,7 @@ public class BackupManagerTest {
@EnumSource(mode = EnumSource.Mode.INCLUDE, names = {"MEDIA", "ALL"})
public void expireBackup(ExpiredBackup.ExpirationType expirationType) {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);
backupManager.createMessageBackupUploadDescriptor(backupUser).join();
backupManager.createMessageBackupUploadDescriptor(backupUser);
final String expectedPrefixToDelete = switch (expirationType) {
case ALL -> backupUser.backupDir();
@@ -1020,7 +1019,7 @@ public class BackupManagerTest {
@Test
public void deleteBackupPaginated() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);
backupManager.createMessageBackupUploadDescriptor(backupUser).join();
backupManager.createMessageBackupUploadDescriptor(backupUser);
final ExpiredBackup expiredBackup = expiredBackup(ExpiredBackup.ExpirationType.MEDIA, backupUser);
final String mediaPrefix = expiredBackup.prefixToDelete() + "/";

View File

@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers;
import static org.assertj.core.api.Assertions.assertThat;
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.reset;
import static org.mockito.Mockito.verify;
@@ -111,8 +112,8 @@ public class ArchiveControllerTest {
reset(backupAuthManager);
reset(backupManager);
when(accountsManager.getByAccountIdentifierAsync(AuthHelper.VALID_UUID))
.thenReturn(CompletableFuture.completedFuture(Optional.of(AuthHelper.VALID_ACCOUNT)));
when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID))
.thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
}
@ParameterizedTest
@@ -164,9 +165,7 @@ public class ArchiveControllerTest {
}
@Test
public void setBackupId() {
when(backupAuthManager.commitBackupId(any(), any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
public void setBackupId() throws RateLimitExceededException {
final Response response = resources.getJerseyTest()
.target("v1/archives/backupid")
.request()
@@ -184,9 +183,7 @@ public class ArchiveControllerTest {
}
@Test
public void setBackupIdPartial() {
when(backupAuthManager.commitBackupId(any(), any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
public void setBackupIdPartial() throws RateLimitExceededException {
final Response response = resources.getJerseyTest()
.target("v1/archives/backupid")
.request()
@@ -210,8 +207,7 @@ public class ArchiveControllerTest {
})
public void backupIdLimits(boolean hasPermits, long waitSeconds) {
when(backupAuthManager.checkBackupIdRotationLimit(any()))
.thenReturn(CompletableFuture.completedFuture(
new BackupAuthManager.BackupIdRotationLimit(hasPermits, Duration.ofSeconds(waitSeconds))));
.thenReturn(new BackupAuthManager.BackupIdRotationLimit(hasPermits, Duration.ofSeconds(waitSeconds)));
final ArchiveController.BackupIdLimitResponse response = resources.getJerseyTest()
.target("v1/archives/backupid/limits")
@@ -233,7 +229,6 @@ public class ArchiveControllerTest {
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")
@@ -248,8 +243,6 @@ public class ArchiveControllerTest {
@Test
public void setBadPublicKey() throws VerificationFailedException {
when(backupManager.setPublicKey(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
final Response response = resources.getJerseyTest()
@@ -265,8 +258,6 @@ public class ArchiveControllerTest {
@Test
public void setMissingPublicKey() throws VerificationFailedException {
when(backupManager.setPublicKey(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
final Response response = resources.getJerseyTest()
@@ -280,8 +271,6 @@ public class ArchiveControllerTest {
@Test
public void setPublicKey() throws VerificationFailedException {
when(backupManager.setPublicKey(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
final Response response = resources.getJerseyTest()
@@ -312,20 +301,15 @@ public class ArchiveControllerTest {
public static Stream<Arguments> setBackupIdException() {
return Stream.of(
Arguments.of(new RateLimitExceededException(null), false, 429),
Arguments.of(Status.INVALID_ARGUMENT.withDescription("async").asRuntimeException(), false, 400),
Arguments.of(Status.INVALID_ARGUMENT.withDescription("sync").asRuntimeException(), true, 400)
Arguments.of(new RateLimitExceededException(null), 429),
Arguments.of(Status.INVALID_ARGUMENT.withDescription("test").asRuntimeException(), 400)
);
}
@ParameterizedTest
@MethodSource
public void setBackupIdException(final Exception ex, final boolean sync, final int expectedStatus) {
if (sync) {
when(backupAuthManager.commitBackupId(any(), any(), any(), any())).thenThrow(ex);
} else {
when(backupAuthManager.commitBackupId(any(), any(), any(), any())).thenReturn(CompletableFuture.failedFuture(ex));
}
public void setBackupIdException(final Exception ex, final int expectedStatus) throws RateLimitExceededException {
doThrow(ex).when(backupAuthManager).commitBackupId(any(), any(), any(), any());
final Response response = resources.getJerseyTest()
.target("v1/archives/backupid")
.request()
@@ -349,7 +333,7 @@ public class ArchiveControllerTest {
expectedCredentialsByType.forEach((credentialType, expectedCredentials) ->
when(backupAuthManager.getBackupAuthCredentials(any(), eq(credentialType), eq(expectedRange)))
.thenReturn(CompletableFuture.completedFuture(expectedCredentials)));
.thenReturn(expectedCredentials));
final ArchiveController.BackupAuthCredentialsResponse credentialResponse = resources.getJerseyTest()
.target("v1/archives/auth")
@@ -405,9 +389,9 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
when(backupManager.backupInfo(any())).thenReturn(CompletableFuture.completedFuture(new BackupManager.BackupInfo(
1, "myBackupDir", "myMediaDir", "filename", Optional.empty())));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
when(backupManager.backupInfo(any()))
.thenReturn(new BackupManager.BackupInfo(1, "myBackupDir", "myMediaDir", "filename", Optional.empty()));
final ArchiveController.BackupInfoResponse response = resources.getJerseyTest()
.target("v1/archives")
.request()
@@ -425,7 +409,7 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
final byte[][] mediaIds = new byte[][]{TestRandomUtil.nextBytes(15), TestRandomUtil.nextBytes(15)};
when(backupManager.copyToBackup(any(), any()))
.thenReturn(Flux.just(
@@ -470,7 +454,7 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
final byte[][] mediaIds = IntStream.range(0, 4).mapToObj(i -> TestRandomUtil.nextBytes(15)).toArray(byte[][]::new);
when(backupManager.copyToBackup(any(), any()))
@@ -528,7 +512,7 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
final byte[][] mediaIds = new byte[][]{TestRandomUtil.nextBytes(15), TestRandomUtil.nextBytes(15)};
final Response r = resources.getJerseyTest()
.target("v1/archives/media/batch")
@@ -561,17 +545,17 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
final byte[] mediaId = TestRandomUtil.nextBytes(15);
final Optional<String> expectedCursor = cursorProvided ? Optional.of("myCursor") : Optional.empty();
final Optional<String> returnedCursor = cursorReturned ? Optional.of("newCursor") : Optional.empty();
when(backupManager.list(any(), eq(expectedCursor), eq(17)))
.thenReturn(CompletableFuture.completedFuture(new BackupManager.ListMediaResult(
.thenReturn(new BackupManager.ListMediaResult(
List.of(new BackupManager.StorageDescriptorWithLength(1, mediaId, 100)),
returnedCursor
)));
));
WebTarget target = resources.getJerseyTest()
.target("v1/archives/media/")
@@ -596,7 +580,7 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(BackupLevel.PAID,
messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
final ArchiveController.DeleteMedia deleteRequest = new ArchiveController.DeleteMedia(
IntStream
@@ -632,10 +616,9 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
when(backupManager.createMessageBackupUploadDescriptor(any()))
.thenReturn(CompletableFuture.completedFuture(
new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org")));
.thenReturn(new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org"));
final WebTarget builder = resources.getJerseyTest().target("v1/archives/upload/form");
final Response response = uploadLength
@@ -658,14 +641,13 @@ public class ArchiveControllerTest {
}
@Test
public void mediaUploadForm() throws VerificationFailedException {
public void mediaUploadForm() throws VerificationFailedException, RateLimitExceededException {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
when(backupManager.createTemporaryAttachmentUploadDescriptor(any()))
.thenReturn(CompletableFuture.completedFuture(
new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org")));
.thenReturn(new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org"));
final ArchiveController.UploadDescriptorResponse desc = resources.getJerseyTest()
.target("v1/archives/media/upload/form")
.request()
@@ -678,8 +660,7 @@ public class ArchiveControllerTest {
assertThat(desc.signedUploadLocation()).isEqualTo("example.org");
// rate limit
when(backupManager.createTemporaryAttachmentUploadDescriptor(any()))
.thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null)));
when(backupManager.createTemporaryAttachmentUploadDescriptor(any())).thenThrow(new RateLimitExceededException(null));
final Response response = resources.getJerseyTest()
.target("v1/archives/media/upload/form")
.request()
@@ -694,7 +675,7 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
when(backupManager.generateReadAuth(any(), eq(3))).thenReturn(Map.of("key", "value"));
final ArchiveController.ReadAuthResponse response = resources.getJerseyTest()
.target("v1/archives/auth/read")
@@ -712,7 +693,7 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
final ExternalServiceCredentials credentials = new ExternalServiceCredentials("username", "password");
when(backupManager.generateSvrbAuth(any())).thenReturn(credentials);
final ExternalServiceCredentials response = resources.getJerseyTest()
@@ -751,8 +732,7 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
when(backupManager.deleteEntireBackup(any())).thenReturn(CompletableFuture.completedFuture(null));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
Response response = resources.getJerseyTest()
.target("v1/archives/")
.request()
@@ -767,7 +747,7 @@ public class ArchiveControllerTest {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
final Response r = resources.getJerseyTest()
.target("v1/archives/media")
.request()

View File

@@ -29,7 +29,6 @@ import java.util.Base64;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.glassfish.jersey.server.ServerProperties;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.jupiter.api.BeforeEach;
@@ -45,7 +44,6 @@ import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;
import org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException;
@@ -206,11 +204,6 @@ class DeviceCheckControllerTest {
{"action": "backup", "challenge": "embeddedChallenge"}
""";
when(backupAuthManager.extendBackupVoucher(any(), eq(new Account.BackupVoucher(
REDEMPTION_LEVEL,
clock.instant().plus(REDEMPTION_DURATION)))))
.thenReturn(CompletableFuture.completedFuture(null));
final Response response = resources.getJerseyTest()
.target("v1/devicecheck/assert")
.queryParam("keyId", Base64.getUrlEncoder().encodeToString(keyId))

View File

@@ -90,14 +90,13 @@ class BackupsAnonymousGrpcServiceTest extends
@BeforeEach
void setup() {
when(backupManager.authenticateBackupUser(any(), any(), any()))
when(backupManager.authenticateBackupUserAsync(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(
backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
}
@Test
void setPublicKey() {
when(backupManager.setPublicKey(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
assertThatNoException().isThrownBy(() -> unauthenticatedServiceStub().setPublicKey(SetPublicKeyRequest.newBuilder()
.setPublicKey(ByteString.copyFrom(ECKeyPair.generate().getPublicKey().serialize()))
.setSignedPresentation(signedPresentation(presentation))
@@ -106,7 +105,6 @@ class BackupsAnonymousGrpcServiceTest extends
@Test
void setBadPublicKey() {
when(backupManager.setPublicKey(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() ->
unauthenticatedServiceStub().setPublicKey(SetPublicKeyRequest.newBuilder()
.setPublicKey(ByteString.copyFromUtf8("aaaaa")) // Invalid public key
@@ -214,8 +212,8 @@ class BackupsAnonymousGrpcServiceTest extends
@Test
void getBackupInfo() {
when(backupManager.backupInfo(any())).thenReturn(CompletableFuture.completedFuture(new BackupManager.BackupInfo(
1, "myBackupDir", "myMediaDir", "filename", Optional.empty())));
when(backupManager.backupInfo(any()))
.thenReturn(new BackupManager.BackupInfo(1, "myBackupDir", "myMediaDir", "filename", Optional.empty()));
final GetBackupInfoResponse response = unauthenticatedServiceStub().getBackupInfo(GetBackupInfoRequest.newBuilder()
.setSignedPresentation(signedPresentation(presentation))
@@ -240,9 +238,9 @@ class BackupsAnonymousGrpcServiceTest extends
final int limit = 17;
when(backupManager.list(any(), eq(expectedCursor), eq(limit)))
.thenReturn(CompletableFuture.completedFuture(new BackupManager.ListMediaResult(
.thenReturn(new BackupManager.ListMediaResult(
List.of(new BackupManager.StorageDescriptorWithLength(1, mediaId, 100)),
returnedCursor)));
returnedCursor));
final ListMediaRequest.Builder request = ListMediaRequest.newBuilder()
.setSignedPresentation(signedPresentation(presentation))
@@ -280,10 +278,9 @@ class BackupsAnonymousGrpcServiceTest extends
}
@Test
void mediaUploadForm() {
void mediaUploadForm() throws RateLimitExceededException {
when(backupManager.createTemporaryAttachmentUploadDescriptor(any()))
.thenReturn(CompletableFuture.completedFuture(
new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org")));
.thenReturn(new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org"));
final GetUploadFormRequest request = GetUploadFormRequest.newBuilder()
.setMedia(GetUploadFormRequest.MediaUploadType.getDefaultInstance())
.setSignedPresentation(signedPresentation(presentation))
@@ -298,7 +295,7 @@ class BackupsAnonymousGrpcServiceTest extends
// rate limit
Duration duration = Duration.ofSeconds(10);
when(backupManager.createTemporaryAttachmentUploadDescriptor(any()))
.thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(duration)));
.thenThrow(new RateLimitExceededException(duration));
GrpcTestUtils.assertRateLimitExceeded(duration, () -> unauthenticatedServiceStub().getUploadForm(request));
}
@@ -314,8 +311,7 @@ class BackupsAnonymousGrpcServiceTest extends
@MethodSource
public void messagesUploadForm(Optional<Long> uploadLength, boolean expectSuccess) {
when(backupManager.createMessageBackupUploadDescriptor(any()))
.thenReturn(CompletableFuture.completedFuture(
new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org")));
.thenReturn(new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org"));
final GetUploadFormRequest.MessagesUploadType.Builder builder = GetUploadFormRequest.MessagesUploadType.newBuilder();
uploadLength.ifPresent(builder::setUploadLength);
final GetUploadFormRequest request = GetUploadFormRequest.newBuilder()

View File

@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.grpc;
import static org.assertj.core.api.Assertions.assertThat;
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.verify;
import static org.mockito.Mockito.when;
@@ -90,15 +91,14 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
when(device.isPrimary()).thenReturn(true);
when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI))
.thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))
.thenReturn(Optional.of(account));
when(account.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(device));
}
@Test
void setBackupId() {
when(backupAuthManager.commitBackupId(any(), any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
void setBackupId() throws RateLimitExceededException {
authenticatedServiceStub().setBackupId(
SetBackupIdRequest.newBuilder()
.setMediaBackupAuthCredentialRequest(ByteString.copyFrom(mediaAuthCredRequest.serialize()))
@@ -111,10 +111,7 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
@ParameterizedTest
@ValueSource(booleans = {false, true})
void setBackupIdPartial(boolean media) {
when(backupAuthManager.commitBackupId(any(), any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
void setBackupIdPartial(boolean media) throws RateLimitExceededException {
final SetBackupIdRequest.Builder builder = SetBackupIdRequest.newBuilder();
if (media) {
builder.setMediaBackupAuthCredentialRequest(ByteString.copyFrom(mediaAuthCredRequest.serialize()));
@@ -143,23 +140,17 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
public static Stream<Arguments> setBackupIdException() {
return Stream.of(
Arguments.of(new RateLimitExceededException(null), false, Status.RESOURCE_EXHAUSTED),
Arguments.of(Status.INVALID_ARGUMENT.withDescription("async").asRuntimeException(), false,
Status.INVALID_ARGUMENT),
Arguments.of(Status.INVALID_ARGUMENT.withDescription("sync").asRuntimeException(), true,
Arguments.of(new RateLimitExceededException(null), Status.RESOURCE_EXHAUSTED),
Arguments.of(Status.INVALID_ARGUMENT.withDescription("test").asRuntimeException(),
Status.INVALID_ARGUMENT)
);
}
@ParameterizedTest
@MethodSource
void setBackupIdException(final Exception ex, final boolean sync, final Status expected) {
if (sync) {
when(backupAuthManager.commitBackupId(any(), any(), any(), any())).thenThrow(ex);
} else {
when(backupAuthManager.commitBackupId(any(), any(), any(), any()))
.thenReturn(CompletableFuture.failedFuture(ex));
}
void setBackupIdException(final Exception ex, final Status expected)
throws RateLimitExceededException {
doThrow(ex).when(backupAuthManager).commitBackupId(any(), any(), any(), any());
GrpcTestUtils.assertStatusException(
expected, () -> authenticatedServiceStub().setBackupId(SetBackupIdRequest.newBuilder()
@@ -180,8 +171,6 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
final ReceiptCredential receiptCredential = clientOps.receiveReceiptCredential(rcrc, rcr);
final ReceiptCredentialPresentation presentation = clientOps.createReceiptCredentialPresentation(receiptCredential);
when(backupAuthManager.redeemReceipt(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
.setPresentation(ByteString.copyFrom(presentation.serialize()))
.build());
@@ -203,7 +192,7 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
expectedCredentialsByType.forEach((credentialType, expectedCredentials) ->
when(backupAuthManager.getBackupAuthCredentials(any(), eq(credentialType), eq(expectedRange)))
.thenReturn(CompletableFuture.completedFuture(expectedCredentials)));
.thenReturn(expectedCredentials));
final GetBackupAuthCredentialsResponse credentialResponse = authenticatedServiceStub().getBackupAuthCredentials(
GetBackupAuthCredentialsRequest.newBuilder()

View File

@@ -15,6 +15,7 @@ import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.protobuf.StatusProto;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -120,6 +121,20 @@ class ErrorMappingInterceptorTest {
client.echo(EchoRequest.getDefaultInstance()));
}
@Test
public void mapWrappedIOExceptionsSimple() throws Exception {
server = InProcessServerBuilder.forName("ErrorMappingInterceptorTest")
.directExecutor()
.addService(new SimpleEchoServiceErrorImpl(new CompletionException(new UncheckedIOException(new IOException("test")))))
.intercept(new ErrorMappingInterceptor())
.build()
.start();
final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);
GrpcTestUtils.assertStatusException(Status.UNAVAILABLE, "UNAVAILABLE", () ->
client.echo(EchoRequest.getDefaultInstance()));
}
static class ReactorEchoServiceErrorImpl extends ReactorEchoServiceGrpc.EchoServiceImplBase {