Convert backup services to use new error model

This commit is contained in:
ravi-signal
2026-01-23 15:25:15 -05:00
committed by GitHub
parent 12d9637f21
commit 5b1d4ce95e
28 changed files with 1105 additions and 764 deletions

View File

@@ -166,6 +166,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter;
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.limits.RedisMessageDeliveryLoopMonitor;
import org.whispersystems.textsecuregcm.mappers.BackupExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;
@@ -1200,6 +1201,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new ObsoletePhoneNumberFormatExceptionMapper(),
new RegistrationServiceSenderExceptionMapper(),
new SubscriptionExceptionMapper(),
new BackupExceptionMapper(),
new JsonMappingExceptionMapper()
).forEach(exceptionMapper -> {
environment.jersey().register(exceptionMapper);

View File

@@ -5,7 +5,8 @@
package org.whispersystems.textsecuregcm.backup;
import io.grpc.Status;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
@@ -98,15 +99,14 @@ public class BackupAuthManager {
final Account account,
final Device device,
final Optional<BackupAuthCredentialRequest> messagesBackupCredentialRequest,
final Optional<BackupAuthCredentialRequest> mediaBackupCredentialRequest) throws RateLimitExceededException {
final Optional<BackupAuthCredentialRequest> mediaBackupCredentialRequest)
throws RateLimitExceededException, BackupPermissionException, BackupInvalidArgumentException {
if (!device.isPrimary()) {
throw Status.PERMISSION_DENIED.withDescription("Only primary device can set backup-id").asRuntimeException();
throw new BackupPermissionException("Only primary device can set backup-id");
}
if (messagesBackupCredentialRequest.isEmpty() && mediaBackupCredentialRequest.isEmpty()) {
throw Status.INVALID_ARGUMENT
.withDescription("Must set at least one of message/media credential requests")
.asRuntimeException();
throw new BackupInvalidArgumentException("Must set at least one of message/media credential requests");
}
final byte[] storedMessageCredentialRequest = account.getBackupCredentialRequest(BackupCredentialType.MESSAGES)
@@ -191,7 +191,7 @@ public class BackupAuthManager {
public List<Credential> getBackupAuthCredentials(
final Account account,
final BackupCredentialType credentialType,
final RedemptionRange redemptionRange) {
final RedemptionRange redemptionRange) throws BackupNotFoundException {
// If the account has an expired payment, clear it before continuing
if (hasExpiredVoucher(account)) {
@@ -206,7 +206,7 @@ public class BackupAuthManager {
// fetch the blinded backup-id the account should have previously committed to
final byte[] committedBytes = account.getBackupCredentialRequest(credentialType)
.orElseThrow(() -> Status.NOT_FOUND.withDescription("No blinded backup-id has been added to the account").asRuntimeException());
.orElseThrow(() -> new BackupNotFoundException("No blinded backup-id has been added to the account"));
try {
final BackupLevel defaultBackupLevel = configuredBackupLevel(account);
@@ -224,10 +224,7 @@ public class BackupAuthManager {
})
.toList();
} catch (InvalidInputException e) {
throw Status.INTERNAL
.withDescription("Could not deserialize stored request credential")
.withCause(e)
.asRuntimeException();
throw new UncheckedIOException(new IOException("Could not deserialize stored request credential", e));
}
}
@@ -239,41 +236,34 @@ public class BackupAuthManager {
*/
public void redeemReceipt(
final Account account,
final ReceiptCredentialPresentation receiptCredentialPresentation) {
final ReceiptCredentialPresentation receiptCredentialPresentation)
throws BackupBadReceiptException, BackupInvalidArgumentException, BackupMissingIdCommitmentException {
try {
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
} catch (VerificationFailedException e) {
throw Status.INVALID_ARGUMENT
.withDescription("receipt credential presentation verification failed")
.asRuntimeException();
throw new BackupBadReceiptException("receipt credential presentation verification failed");
}
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
if (clock.instant().isAfter(receiptExpiration)) {
throw Status.INVALID_ARGUMENT.withDescription("receipt is already expired").asRuntimeException();
throw new BackupBadReceiptException("receipt is already expired");
}
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
if (BackupLevelUtil.fromReceiptLevel(receiptLevel) != BackupLevel.PAID) {
throw Status.INVALID_ARGUMENT
.withDescription("server does not recognize the requested receipt level")
.asRuntimeException();
throw new BackupInvalidArgumentException("server does not recognize the requested receipt level");
}
if (account.getBackupCredentialRequest(BackupCredentialType.MEDIA).isEmpty()) {
throw Status.ABORTED
.withDescription("account must have a backup-id commitment")
.asRuntimeException();
throw new BackupMissingIdCommitmentException();
}
boolean receiptAllowed = redeemedReceiptsManager
.put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid())
.join();
if (!receiptAllowed) {
throw Status.INVALID_ARGUMENT
.withDescription("receipt serial is already redeemed")
.asRuntimeException();
throw new BackupBadReceiptException("receipt serial is already redeemed");
}
extendBackupVoucher(account, new Account.BackupVoucher(receiptLevel, receiptExpiration));
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
public class BackupBadReceiptException extends BackupException {
public BackupBadReceiptException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
public class BackupException extends Exception {
public BackupException() {
super();
}
public BackupException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
public class BackupFailedZkAuthenticationException extends BackupException {
public BackupFailedZkAuthenticationException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
public class BackupInvalidArgumentException extends BackupException {
public BackupInvalidArgumentException(final String message) {
super(message);
}
}

View File

@@ -7,8 +7,6 @@ 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;
@@ -128,11 +126,12 @@ public class BackupManager {
* @param presentation a ZK credential presentation that encodes the backupId
* @param signature the signature of the presentation
* @param publicKey the public key of a key-pair that the presentation must be signed with
* @throws BackupFailedZkAuthenticationException If the provided presentation or signature were invalid
*/
public void setPublicKey(
final BackupAuthCredentialPresentation presentation,
final byte[] signature,
final ECPublicKey publicKey) {
final ECPublicKey publicKey) throws BackupFailedZkAuthenticationException {
// Note: this is a special case where we can't validate the presentation signature against the stored public key
// because we are currently setting it. We check against the provided public key, but we must also verify that
@@ -141,16 +140,14 @@ public class BackupManager {
verifyPresentation(presentation).verifySignature(signature, publicKey);
ExceptionUtils.unwrapSupply(
PublicKeyConflictException.class,
BackupPublicKeyConflictException.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")
.increment();
throw Status.UNAUTHENTICATED
.withDescription("public key does not match existing public key for the backup-id")
.asRuntimeException();
return new BackupFailedZkAuthenticationException("The provided public key did not match the stored public key");
});
}
@@ -161,9 +158,11 @@ public class BackupManager {
*
* @param backupUser an already ZK authenticated backup user
* @return the upload form
* @throws BackupPermissionException if the credential does not have the correct level
* @throws BackupWrongCredentialTypeException if the credential does not have the messages type
*/
public BackupUploadDescriptor createMessageBackupUploadDescriptor(
final AuthenticatedBackupUser backupUser) {
final AuthenticatedBackupUser backupUser) throws BackupPermissionException, BackupWrongCredentialTypeException {
checkBackupLevel(backupUser, BackupLevel.FREE);
checkBackupCredentialType(backupUser, BackupCredentialType.MESSAGES);
@@ -173,7 +172,7 @@ public class BackupManager {
}
public BackupUploadDescriptor createTemporaryAttachmentUploadDescriptor(final AuthenticatedBackupUser backupUser)
throws RateLimitExceededException {
throws RateLimitExceededException, BackupPermissionException, BackupWrongCredentialTypeException {
checkBackupLevel(backupUser, BackupLevel.PAID);
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
@@ -189,8 +188,9 @@ public class BackupManager {
* Update the last update timestamps for the backupId in the presentation
*
* @param backupUser an already ZK authenticated backup user
* @throws BackupPermissionException if the credential does not have the correct level
*/
public void ttlRefresh(final AuthenticatedBackupUser backupUser) {
public void ttlRefresh(final AuthenticatedBackupUser backupUser) throws BackupPermissionException {
checkBackupLevel(backupUser, BackupLevel.FREE);
// update message backup TTL
final StoredBackupAttributes storedBackupAttributes = backupsDb.ttlRefresh(backupUser).join();
@@ -229,10 +229,15 @@ public class BackupManager {
*
* @param backupUser an already ZK authenticated backup user
* @return Information about the existing backup
* @throws BackupPermissionException if the credential does not have the correct level
* @throws BackupNotFoundException if the provided backupuser does not exist
*/
public BackupInfo backupInfo(final AuthenticatedBackupUser backupUser) {
public BackupInfo backupInfo(final AuthenticatedBackupUser backupUser)
throws BackupNotFoundException, BackupPermissionException {
checkBackupLevel(backupUser, BackupLevel.FREE);
final BackupsDb.BackupDescription backupDescription = backupsDb.describeBackup(backupUser).join();
final BackupsDb.BackupDescription backupDescription = ExceptionUtils.unwrapSupply(
BackupNotFoundException.class,
() -> backupsDb.describeBackup(backupUser).join());
return new BackupInfo(
backupDescription.cdn(),
backupUser.backupDir(),
@@ -253,46 +258,42 @@ public class BackupManager {
* @return A Flux that emits the locations of the double-encrypted objects on the backup cdn, or includes an error
* detailing why the object could not be copied.
*/
public Flux<CopyResult> copyToBackup(final AuthenticatedBackupUser backupUser, List<CopyParameters> toCopy) {
checkBackupLevel(backupUser, BackupLevel.PAID);
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
public Flux<CopyResult> copyToBackup(final CopyQuota copyQuota) {
final AuthenticatedBackupUser backupUser = copyQuota.backupUser();
final DynamicBackupConfiguration backupConfiguration =
dynamicConfigurationManager.getConfiguration().getBackupConfiguration();
return Mono.fromFuture(() -> allowedCopies(backupUser, toCopy))
.flatMapMany(quotaResult -> Flux.concat(
return Flux.concat(
// Perform copies for requests that fit in our quota, first updating the usage. If the copy fails, our
// estimated quota usage may not be exact since we update usage first. We make a best-effort attempt
// to undo the usage update if we know that the copied failed for sure.
Flux.fromIterable(quotaResult.requestsToCopy())
// Perform copies for requests that fit in our quota, first updating the usage. If the copy fails, our
// estimated quota usage may not be exact since we update usage first. We make a best-effort attempt
// to undo the usage update if we know that the copied failed for sure.
Flux.fromIterable(copyQuota.requestsToCopy())
// Update the usage in reasonable chunk sizes to bound how out of sync our claimed and actual usage gets
.buffer(backupConfiguration.usageCheckpointCount())
.concatMap(copyParameters -> {
final long quotaToConsume = copyParameters.stream()
.mapToLong(CopyParameters::destinationObjectSize)
.sum();
return Mono
.fromFuture(backupsDb.trackMedia(backupUser, copyParameters.size(), quotaToConsume))
.thenMany(Flux.fromIterable(copyParameters));
})
// Update the usage in reasonable chunk sizes to bound how out of sync our claimed and actual usage gets
.buffer(backupConfiguration.usageCheckpointCount())
.concatMap(copyParameters -> {
final long quotaToConsume = copyParameters.stream()
.mapToLong(CopyParameters::destinationObjectSize)
.sum();
return Mono
.fromFuture(backupsDb.trackMedia(backupUser, copyParameters.size(), quotaToConsume))
.thenMany(Flux.fromIterable(copyParameters));
})
// Actually perform the copies now that we've updated the quota
.flatMapSequential(copyParams -> copyToBackup(backupUser, copyParams)
.flatMap(copyResult -> switch (copyResult.outcome()) {
case SUCCESS -> Mono.just(copyResult);
case SOURCE_WRONG_LENGTH, SOURCE_NOT_FOUND, OUT_OF_QUOTA -> Mono
.fromFuture(this.backupsDb.trackMedia(backupUser, -1, -copyParams.destinationObjectSize()))
.thenReturn(copyResult);
}),
backupConfiguration.copyConcurrency(), 1),
// Actually perform the copies now that we've updated the quota
.flatMapSequential(copyParams -> copyToBackup(backupUser, copyParams)
.flatMap(copyResult -> switch (copyResult.outcome()) {
case SUCCESS -> Mono.just(copyResult);
case SOURCE_WRONG_LENGTH, SOURCE_NOT_FOUND, OUT_OF_QUOTA -> Mono
.fromFuture(this.backupsDb.trackMedia(backupUser, -1, -copyParams.destinationObjectSize()))
.thenReturn(copyResult);
}),
backupConfiguration.copyConcurrency(), 1),
// There wasn't enough quota remaining to perform these copies
Flux.fromIterable(quotaResult.requestsToReject())
.map(arg -> new CopyResult(CopyResult.Outcome.OUT_OF_QUOTA, arg.destinationMediaId(), null))
));
// There wasn't enough quota remaining to perform these copies
Flux.fromIterable(copyQuota.requestsToReject())
.map(arg -> new CopyResult(CopyResult.Outcome.OUT_OF_QUOTA, arg.destinationMediaId(), null)));
}
private Mono<CopyResult> copyToBackup(final AuthenticatedBackupUser backupUser, final CopyParameters copyParameters) {
@@ -315,64 +316,67 @@ public class BackupManager {
Mono.just(CopyResult.fromCopyError(throwable, copyParameters.destinationMediaId()).orElseThrow()));
}
private record QuotaResult(List<CopyParameters> requestsToCopy, List<CopyParameters> requestsToReject) {}
public record CopyQuota(AuthenticatedBackupUser backupUser, List<CopyParameters> requestsToCopy, List<CopyParameters> requestsToReject) {
private static CopyQuota create(AuthenticatedBackupUser backupUser, final List<CopyParameters> toCopy, long remainingQuota) {
// Figure out how many of the requested objects fit in the remaining quota
final int index = indexWhereTotalExceeds(toCopy, CopyParameters::destinationObjectSize, remainingQuota);
return new CopyQuota(backupUser, toCopy.subList(0, index), toCopy.subList(index, toCopy.size()));
}
}
/**
* Determine which copy requests can be performed with the user's remaining quota. This does not update the quota.
*
* @param backupUser The user quota to check against
* @param toCopy The proposed copy requests
* @return list of QuotaResult indicating which requests fit into the remaining quota and which requests should be
* @return QuotaResult indicating which requests fit into the remaining quota and which requests should be
* rejected with {@link CopyResult.Outcome#OUT_OF_QUOTA}
* @throws BackupInvalidArgumentException if toCopy contains an invalid copy request
* @throws BackupPermissionException if the credential does not have the correct level
* @throws BackupWrongCredentialTypeException if the credential does not have the media type
*/
private CompletableFuture<QuotaResult> allowedCopies(
public CopyQuota getCopyQuota(
final AuthenticatedBackupUser backupUser,
final List<CopyParameters> toCopy) {
final long totalBytesAdded = toCopy.stream()
.mapToLong(copyParameters -> {
if (copyParameters.sourceLength() > MAX_MEDIA_OBJECT_SIZE || copyParameters.sourceLength() < 0) {
throw Status.INVALID_ARGUMENT
.withDescription("Invalid sourceObject size")
.asRuntimeException();
}
return copyParameters.destinationObjectSize();
})
.sum();
final List<CopyParameters> toCopy)
throws BackupWrongCredentialTypeException, BackupPermissionException, BackupInvalidArgumentException {
checkBackupLevel(backupUser, BackupLevel.PAID);
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
for (CopyParameters copyParameters : toCopy) {
if (copyParameters.sourceLength() > MAX_MEDIA_OBJECT_SIZE || copyParameters.sourceLength() < 0) {
throw new BackupInvalidArgumentException("Invalid sourceObject size");
}
}
final long totalBytesAdded = toCopy.stream().mapToLong(CopyParameters::destinationObjectSize).sum();
final DynamicBackupConfiguration backupConfiguration =
dynamicConfigurationManager.getConfiguration().getBackupConfiguration();
final Duration maxQuotaStaleness = backupConfiguration.maxQuotaStaleness();
final long maxTotalMediaSize = backupConfiguration.maxTotalMediaSize();
return backupsDb.getMediaUsage(backupUser)
.thenComposeAsync(info -> {
long remainingQuota = maxTotalMediaSize - info.usageInfo().bytesUsed();
final boolean canStore = remainingQuota >= totalBytesAdded;
if (canStore || info.lastRecalculationTime().isAfter(clock.instant().minus(maxQuotaStaleness))) {
return CompletableFuture.completedFuture(remainingQuota);
}
final BackupsDb.TimestampedUsageInfo info = backupsDb.getMediaUsage(backupUser).join();
long estimatedRemainingQuota = maxTotalMediaSize - info.usageInfo().bytesUsed();
final boolean canStore = estimatedRemainingQuota >= totalBytesAdded;
if (canStore || info.lastRecalculationTime().isAfter(clock.instant().minus(maxQuotaStaleness))) {
return CopyQuota.create(backupUser, toCopy, estimatedRemainingQuota);
}
// The user is out of quota, and we have not recently recalculated the user's usage. Double check by doing a
// hard recalculation before actually forbidding the user from storing additional media.
return this.remoteStorageManager.calculateBytesUsed(cdnMediaDirectory(backupUser))
.thenCompose(usage -> backupsDb
.setMediaUsage(backupUser, usage)
.thenApply(ignored -> usage))
.whenComplete((newUsage, throwable) -> {
boolean usageChanged = throwable == null && !newUsage.equals(info.usageInfo());
Metrics.counter(USAGE_RECALCULATION_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
Tag.of("usageChanged", String.valueOf(usageChanged))))
.increment();
})
.thenApply(newUsage -> maxTotalMediaSize - newUsage.bytesUsed());
})
.thenApply(remainingQuota -> {
// Figure out how many of the requested objects fit in the remaining quota
final int index = indexWhereTotalExceeds(toCopy, CopyParameters::destinationObjectSize,
remainingQuota);
return new QuotaResult(toCopy.subList(0, index), toCopy.subList(index, toCopy.size()));
});
// The user is out of quota, and we have not recently recalculated the user's usage. Double check by doing a
// hard recalculation before actually forbidding the user from storing additional media.
boolean usageChanged = false;
try {
final UsageInfo usage =
this.remoteStorageManager.calculateBytesUsed(cdnMediaDirectory(backupUser)).toCompletableFuture().join();
backupsDb.setMediaUsage(backupUser, usage).join();
usageChanged = !usage.equals(info.usageInfo());
final long remainingQuota = maxTotalMediaSize - usage.bytesUsed();
return CopyQuota.create(backupUser, toCopy, remainingQuota);
} finally {
Metrics.counter(USAGE_RECALCULATION_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
Tag.of("usageChanged", String.valueOf(usageChanged))))
.increment();
}
}
public record RecalculationResult(UsageInfo oldUsage, UsageInfo newUsage) {}
@@ -413,11 +417,14 @@ public class BackupManager {
* @param backupUser an already ZK authenticated backup user
* @param cdnNumber the cdn number to get backup credentials for
* @return A map of headers to include with CDN requests
* @throws BackupPermissionException if the credential does not have the correct level
* @throws BackupInvalidArgumentException if the provided cdnNumber is invalid
*/
public Map<String, String> generateReadAuth(final AuthenticatedBackupUser backupUser, final int cdnNumber) {
public Map<String, String> generateReadAuth(final AuthenticatedBackupUser backupUser, final int cdnNumber)
throws BackupInvalidArgumentException, BackupPermissionException {
checkBackupLevel(backupUser, BackupLevel.FREE);
if (cdnNumber != 3) {
throw Status.INVALID_ARGUMENT.withDescription("unknown cdn").asRuntimeException();
throw new BackupInvalidArgumentException("unknown cdn");
}
return cdn3BackupCredentialGenerator.readHeaders(backupUser.backupDir());
}
@@ -427,8 +434,11 @@ public class BackupManager {
*
* @param backupUser an already ZK authenticated backup user
* @return the credential that may be used with SVRB
* @throws BackupPermissionException if the credential does not have the correct level
* @throws BackupWrongCredentialTypeException if the credential does not have the messages type
*/
public ExternalServiceCredentials generateSvrbAuth(final AuthenticatedBackupUser backupUser) {
public ExternalServiceCredentials generateSvrbAuth(final AuthenticatedBackupUser backupUser)
throws BackupPermissionException, BackupWrongCredentialTypeException {
checkBackupLevel(backupUser, BackupLevel.FREE);
// Clients may only use SVRB with their messages backup-id
checkBackupCredentialType(backupUser, BackupCredentialType.MESSAGES);
@@ -458,11 +468,12 @@ public class BackupManager {
* @param cursor A cursor returned by a previous call that can be used to resume listing
* @param limit The maximum number of list results to return
* @return A {@link ListMediaResult}
* @throws BackupPermissionException if the credential does not have the correct level
*/
public ListMediaResult list(
final AuthenticatedBackupUser backupUser,
final Optional<String> cursor,
final int limit) {
final int limit) throws BackupPermissionException {
checkBackupLevel(backupUser, BackupLevel.FREE);
final RemoteStorageManager.ListResult result =
remoteStorageManager.list(cdnMediaDirectory(backupUser), cursor, limit).toCompletableFuture().join();
@@ -478,7 +489,7 @@ public class BackupManager {
result.cursor());
}
public void deleteEntireBackup(final AuthenticatedBackupUser backupUser) {
public void deleteEntireBackup(final AuthenticatedBackupUser backupUser) throws BackupPermissionException {
checkBackupLevel(backupUser, BackupLevel.FREE);
final int deletionConcurrency =
@@ -504,15 +515,14 @@ public class BackupManager {
public Flux<StorageDescriptor> deleteMedia(final AuthenticatedBackupUser backupUser,
final List<StorageDescriptor> storageDescriptors) {
final List<StorageDescriptor> storageDescriptors)
throws BackupPermissionException, BackupWrongCredentialTypeException {
checkBackupLevel(backupUser, BackupLevel.FREE);
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
// Check for a cdn we don't know how to process
if (storageDescriptors.stream().anyMatch(sd -> sd.cdn() != remoteStorageManager.cdnNumber())) {
throw Status.INVALID_ARGUMENT
.withDescription("unsupported media cdn provided")
.asRuntimeException();
return Flux.error(new BackupInvalidArgumentException("unsupported media cdn provided"));
}
final DynamicBackupConfiguration backupConfiguration =
dynamicConfigurationManager.getConfiguration().getBackupConfiguration();
@@ -614,65 +624,40 @@ public class BackupManager {
* @param presentation A {@link BackupAuthCredentialPresentation}
* @param signature An XEd25519 signature of the presentation bytes
* @return On authentication success, the authenticated backup-id and backup-tier encoded in the presentation
* @throws BackupFailedZkAuthenticationException If the provided presentation or signature were invalid
*/
public AuthenticatedBackupUser authenticateBackupUser(
final BackupAuthCredentialPresentation presentation,
final byte[] signature,
final String userAgentString) {
return ExceptionUtils.unwrapSupply(
StatusRuntimeException.class,
() -> authenticateBackupUserAsync(presentation, signature, userAgentString).join());
}
final String userAgentString) throws BackupFailedZkAuthenticationException {
/**
* 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 -> {
final UserAgent userAgent = parseUserAgent(userAgentString);
final BackupsDb.AuthenticationData authenticationData = optionalAuthenticationData
.orElseGet(() -> {
Metrics.counter(ZK_AUTHN_COUNTER_NAME, Tags.of(
Tag.of(SUCCESS_TAG_NAME, String.valueOf(false)),
Tag.of(FAILURE_REASON_TAG_NAME, "missing_public_key"),
UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
// There was no stored public key, use a bunk public key so that validation will fail
return new BackupsDb.AuthenticationData(INVALID_PUBLIC_KEY, null, null);
});
final Pair<BackupCredentialType, BackupLevel> credentialTypeAndBackupLevel =
signatureVerifier.verifySignature(signature, authenticationData.publicKey());
return new AuthenticatedBackupUser(
presentation.getBackupId(),
credentialTypeAndBackupLevel.first(),
credentialTypeAndBackupLevel.second(),
authenticationData.backupDir(),
authenticationData.mediaDir(),
userAgent);
})
.thenApply(result -> {
Metrics.counter(ZK_AUTHN_COUNTER_NAME, SUCCESS_TAG_NAME, String.valueOf(true)).increment();
return result;
final Optional<BackupsDb.AuthenticationData> optionalAuthenticationData =
backupsDb.retrieveAuthenticationData(presentation.getBackupId()).join();
final UserAgent userAgent = parseUserAgent(userAgentString);
final BackupsDb.AuthenticationData authenticationData = optionalAuthenticationData
.orElseGet(() -> {
Metrics.counter(ZK_AUTHN_COUNTER_NAME, Tags.of(
Tag.of(SUCCESS_TAG_NAME, String.valueOf(false)),
Tag.of(FAILURE_REASON_TAG_NAME, "missing_public_key"),
UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
// There was no stored public key, use a bunk public key so that validation will fail
return new BackupsDb.AuthenticationData(INVALID_PUBLIC_KEY, null, null);
});
final Pair<BackupCredentialType, BackupLevel> credentialTypeAndBackupLevel =
signatureVerifier.verifySignature(signature, authenticationData.publicKey());
Metrics.counter(ZK_AUTHN_COUNTER_NAME, SUCCESS_TAG_NAME, String.valueOf(true)).increment();
return new AuthenticatedBackupUser(
presentation.getBackupId(),
credentialTypeAndBackupLevel.first(),
credentialTypeAndBackupLevel.second(),
authenticationData.backupDir(),
authenticationData.mediaDir(),
userAgent);
}
/**
@@ -754,7 +739,7 @@ public class BackupManager {
interface PresentationSignatureVerifier {
Pair<BackupCredentialType, BackupLevel> verifySignature(byte[] signature, ECPublicKey publicKey);
Pair<BackupCredentialType, BackupLevel> verifySignature(byte[] signature, ECPublicKey publicKey) throws BackupFailedZkAuthenticationException;
}
/**
@@ -763,7 +748,8 @@ public class BackupManager {
* @param presentation A ZK credential presentation that encodes the backupId and the receipt level of the requester
* @return A function that can be used to verify a signature provided with the presentation
*/
private PresentationSignatureVerifier verifyPresentation(final BackupAuthCredentialPresentation presentation) {
private PresentationSignatureVerifier verifyPresentation(final BackupAuthCredentialPresentation presentation)
throws BackupFailedZkAuthenticationException {
try {
presentation.verify(clock.instant(), serverSecretParams);
} catch (VerificationFailedException e) {
@@ -771,10 +757,7 @@ public class BackupManager {
SUCCESS_TAG_NAME, String.valueOf(false),
FAILURE_REASON_TAG_NAME, "presentation_verification")
.increment();
throw Status.UNAUTHENTICATED
.withDescription("backup auth credential presentation verification failed")
.withCause(e)
.asRuntimeException();
throw new BackupFailedZkAuthenticationException("backup auth credential presentation verification failed");
}
return (signature, publicKey) -> {
if (!publicKey.verifySignature(presentation.serialize(), signature)) {
@@ -782,9 +765,7 @@ public class BackupManager {
SUCCESS_TAG_NAME, String.valueOf(false),
FAILURE_REASON_TAG_NAME, "signature_validation")
.increment();
throw Status.UNAUTHENTICATED
.withDescription("backup auth credential presentation signature verification failed")
.asRuntimeException();
throw new BackupFailedZkAuthenticationException("backup auth credential presentation signature verification failed");
}
return new Pair<>(presentation.getType(), presentation.getBackupLevel());
};
@@ -795,19 +776,18 @@ public class BackupManager {
*
* @param backupUser The backup user to check
* @param backupLevel The authorization level to verify the backupUser has access to
* @throws {@link Status#PERMISSION_DENIED} error if the backup user is not authorized to access {@code backupLevel}
* @throws BackupPermissionException if the backupUser is not authorized to access {@code backupLevel}
*/
@VisibleForTesting
static void checkBackupLevel(final AuthenticatedBackupUser backupUser, final BackupLevel backupLevel) {
static void checkBackupLevel(final AuthenticatedBackupUser backupUser, final BackupLevel backupLevel)
throws BackupPermissionException {
if (backupUser.backupLevel().compareTo(backupLevel) < 0) {
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
Tag.of(FAILURE_REASON_TAG_NAME, "level")))
.increment();
throw Status.PERMISSION_DENIED
.withDescription("credential does not support the requested operation")
.asRuntimeException();
throw new BackupPermissionException("credential does not support the requested operation");
}
}
@@ -816,19 +796,17 @@ public class BackupManager {
*
* @param backupUser The backup user to check
* @param credentialType The credential type to require
* @throws {@link Status#UNAUTHENTICATED} error if the backup user is not authenticated with the given
* @throws BackupWrongCredentialTypeException error if the backup user is not authenticated with the given
* {@code credentialType}
*/
@VisibleForTesting
static void checkBackupCredentialType(final AuthenticatedBackupUser backupUser, final BackupCredentialType credentialType) {
static void checkBackupCredentialType(final AuthenticatedBackupUser backupUser, final BackupCredentialType credentialType) throws BackupWrongCredentialTypeException {
if (backupUser.credentialType() != credentialType) {
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME,
FAILURE_REASON_TAG_NAME, "credential_type")
.increment();
throw Status.UNAUTHENTICATED
.withDescription("wrong credential type for the requested operation")
.asRuntimeException();
throw new BackupWrongCredentialTypeException("wrong credential type for the requested operation");
}
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
public class BackupMissingIdCommitmentException extends BackupException {
public BackupMissingIdCommitmentException() {
super();
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
public class BackupNotFoundException extends BackupException {
public BackupNotFoundException(final String message) {
super(message);
}
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
public class BackupPermissionException extends BackupException {
public BackupPermissionException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,8 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
public class BackupPublicKeyConflictException extends BackupException {
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
public class BackupWrongCredentialTypeException extends BackupException {
public BackupWrongCredentialTypeException(String message) {
super(message);
}
}

View File

@@ -4,8 +4,8 @@
*/
package org.whispersystems.textsecuregcm.backup;
import io.grpc.Status;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
@@ -22,18 +22,12 @@ import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.zkgroup.backups.BackupLevel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.Util;
@@ -133,7 +127,7 @@ public class BackupsDb {
* @param authenticatedBackupLevel The backup level
* @param publicKey The public key to associate with the backup id
* @return A stage that completes when the public key has been set. If the backup-id already has a set public key that
* does not match, the stage will be completed exceptionally with a {@link PublicKeyConflictException}
* does not match, the stage will be completed exceptionally with a {@link BackupPublicKeyConflictException}
*/
CompletableFuture<Void> setPublicKey(
final byte[] authenticatedBackupId,
@@ -152,7 +146,7 @@ public class BackupsDb {
.build())
.exceptionally(ExceptionUtils.marshal(ConditionalCheckFailedException.class, e ->
// There was already a row for this backup-id and it contained a different publicKey
new PublicKeyConflictException()))
new BackupPublicKeyConflictException()))
.thenRun(Util.NOOP);
}
@@ -190,9 +184,7 @@ public class BackupsDb {
private static String getDirName(final Map<String, AttributeValue> item, final String attr) {
return AttributeValues.get(item, attr).map(AttributeValue::s).orElseThrow(() -> {
logger.error("Backups with public keys should have directory names");
return Status.INTERNAL
.withDescription("Backups with public keys must have directory names")
.asRuntimeException();
throw new UncheckedIOException(new IOException("Backups with public keys must have directory names"));
});
}
@@ -208,10 +200,7 @@ public class BackupsDb {
return new ECPublicKey(publicKeyBytes);
} catch (InvalidKeyException e) {
logger.error("Invalid publicKey {}", HexFormat.of().formatHex(publicKeyBytes), e);
throw Status.INTERNAL
.withCause(e)
.withDescription("Could not deserialize stored public key")
.asRuntimeException();
throw new UncheckedIOException(new IOException("Could not deserialize stored public key"));
}
}
@@ -330,6 +319,7 @@ public class BackupsDb {
* @param backupUser an already authorized backup user
* @return A {@link BackupDescription} containing the cdn of the message backup and the total number of media space
* bytes used by the backup user.
* @throws BackupNotFoundException If the provided backupUser's backup-id does not exist
*/
CompletableFuture<BackupDescription> describeBackup(final AuthenticatedBackupUser backupUser) {
return dynamoClient.getItem(GetItemRequest.builder()
@@ -341,7 +331,7 @@ public class BackupsDb {
.build())
.thenApply(response -> {
if (!response.hasItem()) {
throw Status.NOT_FOUND.withDescription("Backup ID not found").asRuntimeException();
throw ExceptionUtils.wrap(new BackupNotFoundException("Backup ID not found"));
}
// If the client hasn't already uploaded a backup, return the cdn we would return if they did create one
final int cdn = AttributeValues.getInt(response.item(), ATTR_CDN, BACKUP_CDN);

View File

@@ -1,6 +0,0 @@
package org.whispersystems.textsecuregcm.backup;
import java.io.IOException;
public class PublicKeyConflictException extends IOException {
}

View File

@@ -67,8 +67,15 @@ 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.BackupBadReceiptException;
import org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException;
import org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;
import org.whispersystems.textsecuregcm.backup.BackupManager;
import org.whispersystems.textsecuregcm.backup.BackupMissingIdCommitmentException;
import org.whispersystems.textsecuregcm.backup.BackupNotFoundException;
import org.whispersystems.textsecuregcm.backup.BackupPermissionException;
import org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;
import org.whispersystems.textsecuregcm.backup.BackupWrongCredentialTypeException;
import org.whispersystems.textsecuregcm.backup.CopyParameters;
import org.whispersystems.textsecuregcm.backup.CopyResult;
import org.whispersystems.textsecuregcm.backup.MediaEncryptionParameters;
@@ -150,7 +157,8 @@ public class ArchiveController {
@ManagedAsync
public void setBackupId(
@Auth final AuthenticatedDevice authenticatedDevice,
@Valid @NotNull final SetBackupIdRequest setBackupIdRequest) throws RateLimitExceededException {
@Valid @NotNull final SetBackupIdRequest setBackupIdRequest)
throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
@@ -230,14 +238,16 @@ public class ArchiveController {
@ApiResponse(responseCode = "409", description = "The target account does not have a backup-id commitment")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ManagedAsync
public void redeemReceipt(
public Response redeemReceipt(
@Auth final AuthenticatedDevice authenticatedDevice,
@Valid @NotNull final RedeemBackupReceiptRequest redeemBackupReceiptRequest) {
@Valid @NotNull final RedeemBackupReceiptRequest redeemBackupReceiptRequest)
throws BackupInvalidArgumentException, BackupMissingIdCommitmentException, BackupBadReceiptException {
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
backupAuthManager.redeemReceipt(account, redeemBackupReceiptRequest.receiptCredentialPresentation());
return Response.noContent().build();
}
public record BackupAuthCredentialsResponse(
@@ -300,7 +310,7 @@ public class ArchiveController {
@Auth AuthenticatedDevice authenticatedDevice,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@NotNull @QueryParam("redemptionStartSeconds") Long startSeconds,
@NotNull @QueryParam("redemptionEndSeconds") Long endSeconds) {
@NotNull @QueryParam("redemptionEndSeconds") Long endSeconds) throws BackupNotFoundException {
final Map<BackupCredentialType, List<BackupAuthCredentialsResponse.BackupAuthCredential>> credentialsByType =
new HashMap<>();
@@ -391,6 +401,7 @@ public class ArchiveController {
summary = "Get CDN read credentials",
description = "Retrieve credentials used to read objects stored on the backup cdn")
@ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ReadAuthResponse.class)))
@ApiResponse(responseCode = "400", description = "Bad arguments. The request may have been made on an authenticated channel, or an invalid cdn number was provided")
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
@ManagedAsync
@@ -406,7 +417,8 @@ public class ArchiveController {
@NotNull
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,
@NotNull @Parameter(description = "The number of the CDN to get credentials for") @QueryParam("cdn") final Integer cdn) {
@NotNull @Parameter(description = "The number of the CDN to get credentials for") @QueryParam("cdn") final Integer cdn)
throws BackupFailedZkAuthenticationException, BackupInvalidArgumentException, BackupPermissionException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
@@ -436,7 +448,8 @@ 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 BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
@@ -487,7 +500,8 @@ 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 BackupFailedZkAuthenticationException, BackupNotFoundException, BackupPermissionException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
@@ -536,7 +550,8 @@ public class ArchiveController {
@NotNull
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,
@Valid @NotNull SetPublicKeyRequest setPublicKeyRequest) {
@Valid @NotNull SetPublicKeyRequest setPublicKeyRequest)
throws BackupFailedZkAuthenticationException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
@@ -578,7 +593,8 @@ public class ArchiveController {
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,
@Parameter(description = "The size of the message backup to upload in bytes")
@QueryParam("uploadLength") final Optional<Long> uploadLength) {
@QueryParam("uploadLength") final Optional<Long> uploadLength)
throws BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
@@ -630,7 +646,7 @@ public class ArchiveController {
@Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))
@NotNull
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature)
throws RateLimitExceededException {
throws RateLimitExceededException, BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
@@ -723,15 +739,17 @@ public class ArchiveController {
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,
@NotNull
@Valid final ArchiveController.CopyMediaRequest copyMediaRequest) {
@Valid final ArchiveController.CopyMediaRequest copyMediaRequest)
throws BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException, BackupInvalidArgumentException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final CopyResult copyResult =
backupManager.copyToBackup(backupUser, List.of(copyMediaRequest.toCopyParameters())).next()
final BackupManager.CopyQuota copyQuota =
backupManager.getCopyQuota(backupUser, List.of(copyMediaRequest.toCopyParameters()));
final CopyResult copyResult = backupManager.copyToBackup(copyQuota).next()
.blockOptional()
.orElseThrow(() -> new IllegalStateException("Non empty copy request must return result"));
backupMetrics.updateCopyCounter(copyResult, UserAgentTagUtil.getPlatformTag(userAgent));
@@ -824,7 +842,8 @@ public class ArchiveController {
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,
@NotNull
@Valid final ArchiveController.CopyMediaBatchRequest copyMediaRequest) {
@Valid final ArchiveController.CopyMediaBatchRequest copyMediaRequest)
throws BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException, BackupInvalidArgumentException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
@@ -832,7 +851,8 @@ public class ArchiveController {
final Stream<CopyParameters> copyParams = copyMediaRequest.items().stream().map(CopyMediaRequest::toCopyParameters);
final AuthenticatedBackupUser backupUser =
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final List<CopyMediaBatchResponse.Entry> copyResults = backupManager.copyToBackup(backupUser, copyParams.toList())
final BackupManager.CopyQuota copyQuota = backupManager.getCopyQuota(backupUser, copyParams.toList());
final List<CopyMediaBatchResponse.Entry> copyResults = backupManager.copyToBackup(copyQuota)
.doOnNext(result -> backupMetrics.updateCopyCounter(result, UserAgentTagUtil.getPlatformTag(userAgent)))
.map(CopyMediaBatchResponse.Entry::fromCopyResult)
.collectList().block();
@@ -861,7 +881,8 @@ 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 BackupFailedZkAuthenticationException, BackupPermissionException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
@@ -934,7 +955,8 @@ public class ArchiveController {
@QueryParam("cursor") final Optional<String> cursor,
@Parameter(description = "The number of entries to return per call")
@QueryParam("limit") final Optional<@Min(1) @Max(10_000) Integer> limit) {
@QueryParam("limit") final Optional<@Min(1) @Max(10_000) Integer> limit)
throws BackupPermissionException, BackupFailedZkAuthenticationException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
@@ -987,7 +1009,8 @@ public class ArchiveController {
@NotNull
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,
@Valid @NotNull DeleteMedia deleteMedia) {
@Valid @NotNull DeleteMedia deleteMedia)
throws BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}
@@ -1020,7 +1043,8 @@ 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 BackupPermissionException, BackupFailedZkAuthenticationException {
if (account.isPresent()) {
throw new BadRequestException("must not use authenticated connection for anonymous operations");
}

View File

@@ -5,14 +5,13 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Flow;
import org.signal.chat.backup.CopyMediaRequest;
import org.signal.chat.backup.CopyMediaResponse;
import org.signal.chat.backup.DeleteAllRequest;
import org.signal.chat.backup.DeleteAllResponse;
import org.signal.chat.backup.DeleteMediaItem;
import org.signal.chat.backup.DeleteMediaRequest;
import org.signal.chat.backup.DeleteMediaResponse;
import org.signal.chat.backup.GetBackupInfoRequest;
@@ -31,14 +30,21 @@ 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.chat.errors.FailedPrecondition;
import org.signal.chat.errors.FailedZkAuthentication;
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.BackupFailedZkAuthenticationException;
import org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;
import org.whispersystems.textsecuregcm.backup.BackupManager;
import org.whispersystems.textsecuregcm.backup.BackupNotFoundException;
import org.whispersystems.textsecuregcm.backup.BackupPermissionException;
import org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;
import org.whispersystems.textsecuregcm.backup.BackupWrongCredentialTypeException;
import org.whispersystems.textsecuregcm.backup.CopyParameters;
import org.whispersystems.textsecuregcm.backup.MediaEncryptionParameters;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
@@ -59,45 +65,75 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
}
@Override
public GetCdnCredentialsResponse getCdnCredentials(final GetCdnCredentialsRequest request) {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
return GetCdnCredentialsResponse.newBuilder()
.putAllHeaders(backupManager.generateReadAuth(backupUser, request.getCdn()))
.build();
public GetCdnCredentialsResponse getCdnCredentials(final GetCdnCredentialsRequest request)
throws BackupInvalidArgumentException, BackupPermissionException {
try {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
return GetCdnCredentialsResponse.newBuilder()
.setCdnCredentials(GetCdnCredentialsResponse.CdnCredentials.newBuilder()
.putAllHeaders(backupManager.generateReadAuth(backupUser, request.getCdn())))
.build();
} catch (BackupFailedZkAuthenticationException e) {
return GetCdnCredentialsResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())
.build();
}
}
@Override
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();
public GetSvrBCredentialsResponse getSvrBCredentials(final GetSvrBCredentialsRequest request)
throws BackupWrongCredentialTypeException, BackupPermissionException {
try {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
final ExternalServiceCredentials credentials = backupManager.generateSvrbAuth(backupUser);
return GetSvrBCredentialsResponse.newBuilder()
.setSvrbCredentials(GetSvrBCredentialsResponse.SvrBCredentials.newBuilder()
.setUsername(credentials.username())
.setPassword(credentials.password()))
.build();
} catch (BackupFailedZkAuthenticationException e) {
return GetSvrBCredentialsResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())
.build();
}
}
@Override
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();
public GetBackupInfoResponse getBackupInfo(final GetBackupInfoRequest request)
throws BackupNotFoundException, BackupPermissionException {
try {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser);
return GetBackupInfoResponse.newBuilder().setBackupInfo(GetBackupInfoResponse.BackupInfo.newBuilder()
.setBackupName(info.messageBackupKey())
.setCdn(info.cdn())
.setBackupDir(info.backupSubdir())
.setMediaDir(info.mediaSubdir())
.setUsedSpace(info.mediaUsedSpace().orElse(0L)))
.build();
} catch (BackupFailedZkAuthenticationException e) {
return GetBackupInfoResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())
.build();
}
}
@Override
public RefreshResponse refresh(final RefreshRequest request) {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
backupManager.ttlRefresh(backupUser);
return RefreshResponse.getDefaultInstance();
public RefreshResponse refresh(final RefreshRequest request) throws BackupPermissionException {
try {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
backupManager.ttlRefresh(backupUser);
return RefreshResponse.getDefaultInstance();
} catch (BackupFailedZkAuthenticationException e) {
return RefreshResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())
.build();
}
}
@Override
public SetPublicKeyResponse setPublicKey(final SetPublicKeyRequest request) {
public SetPublicKeyResponse setPublicKey(final SetPublicKeyRequest request)
throws BackupFailedZkAuthenticationException {
final ECPublicKey publicKey = deserialize(ECPublicKey::new, request.getPublicKey().toByteArray());
final BackupAuthCredentialPresentation presentation = deserialize(
BackupAuthCredentialPresentation::new,
@@ -110,45 +146,67 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
@Override
public GetUploadFormResponse getUploadForm(final GetUploadFormRequest request) throws RateLimitExceededException {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
final BackupUploadDescriptor uploadDescriptor = switch (request.getUploadTypeCase()) {
public GetUploadFormResponse getUploadForm(final GetUploadFormRequest request)
throws RateLimitExceededException, BackupWrongCredentialTypeException, BackupPermissionException {
final AuthenticatedBackupUser backupUser;
try {
backupUser = authenticateBackupUser(request.getSignedPresentation());
} catch (BackupFailedZkAuthenticationException e) {
return GetUploadFormResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())
.build();
}
final GetUploadFormResponse.Builder builder = GetUploadFormResponse.newBuilder();
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();
builder.setExceedsMaxUploadLength(FailedPrecondition.getDefaultInstance());
} else {
final BackupUploadDescriptor uploadDescriptor = backupManager.createMessageBackupUploadDescriptor(backupUser);
builder.setUploadForm(builder.getUploadFormBuilder()
.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();
case MEDIA -> {
final BackupUploadDescriptor uploadDescriptor = backupManager.createTemporaryAttachmentUploadDescriptor(
backupUser);
builder.setUploadForm(builder.getUploadFormBuilder()
.setCdn(uploadDescriptor.cdn())
.setKey(uploadDescriptor.key())
.setSignedUploadLocation(uploadDescriptor.signedUploadLocation())
.putAllHeaders(uploadDescriptor.headers())).build();
}
case UPLOADTYPE_NOT_SET -> throw GrpcExceptions.fieldViolation("upload_type", "Must set upload_type");
}
return builder.build();
}
@Override
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(),
// uint32 in proto, make sure it fits in a signed int
fromUnsignedExact(item.getObjectLength()),
new MediaEncryptionParameters(item.getEncryptionKey().toByteArray(), item.getHmacKey().toByteArray()),
item.getMediaId().toByteArray())).toList()))
public Flow.Publisher<CopyMediaResponse> copyMedia(final CopyMediaRequest request)
throws BackupWrongCredentialTypeException, BackupPermissionException, BackupInvalidArgumentException {
final BackupManager.CopyQuota copyQuota;
try {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
copyQuota = backupManager.getCopyQuota(backupUser,
request.getItemsList().stream().map(item -> new CopyParameters(
item.getSourceAttachmentCdn(), item.getSourceKey(),
// uint32 in proto, make sure it fits in a signed int
fromUnsignedExact(item.getObjectLength()),
new MediaEncryptionParameters(item.getEncryptionKey().toByteArray(), item.getHmacKey().toByteArray()),
item.getMediaId().toByteArray())).toList());
} catch (BackupFailedZkAuthenticationException e) {
return JdkFlowAdapter.publisherToFlowPublisher(Mono.just(CopyMediaResponse
.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))
.build()));
}
return JdkFlowAdapter.publisherToFlowPublisher(backupManager.copyToBackup(copyQuota)
.doOnNext(result -> backupMetrics.updateCopyCounter(
result,
UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
@@ -167,79 +225,107 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
.setSourceNotFound(CopyMediaResponse.SourceNotFound.getDefaultInstance());
};
return builder.build();
});
return JdkFlowAdapter.publisherToFlowPublisher(flux);
}));
}
@Override
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());
public ListMediaResponse listMedia(final ListMediaRequest request) throws BackupPermissionException {
try {
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.ListResult.Builder builder = ListMediaResponse.ListResult.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 ListMediaResponse.newBuilder().setListResult(builder).build();
} catch (BackupFailedZkAuthenticationException e) {
return ListMediaResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))
.build();
}
builder
.setBackupDir(backupUser.backupDir())
.setMediaDir(backupUser.mediaDir());
listResult.cursor().ifPresent(builder::setCursor);
return builder.build();
}
@Override
public DeleteAllResponse deleteAll(final DeleteAllRequest request) {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
backupManager.deleteEntireBackup(backupUser);
return DeleteAllResponse.getDefaultInstance();
public DeleteAllResponse deleteAll(final DeleteAllRequest request) throws BackupPermissionException {
try {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
backupManager.deleteEntireBackup(backupUser);
return DeleteAllResponse.getDefaultInstance();
} catch (BackupFailedZkAuthenticationException e) {
return DeleteAllResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))
.build();
}
}
@Override
public Flow.Publisher<DeleteMediaResponse> deleteMedia(final DeleteMediaRequest request) {
return JdkFlowAdapter.publisherToFlowPublisher(Mono
.fromFuture(() -> authenticateBackupUserAsync(request.getSignedPresentation()))
.flatMapMany(backupUser -> backupManager.deleteMedia(backupUser, request
.getItemsList()
.stream()
.map(item -> new BackupManager.StorageDescriptor(item.getCdn(), item.getMediaId().toByteArray()))
.toList()))
public Flow.Publisher<DeleteMediaResponse> deleteMedia(final DeleteMediaRequest request)
throws BackupWrongCredentialTypeException, BackupPermissionException {
final Flux<BackupManager.StorageDescriptor> deleteItems;
try {
final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());
deleteItems = backupManager.deleteMedia(backupUser, request
.getItemsList()
.stream()
.map(item -> new BackupManager.StorageDescriptor(item.getCdn(), item.getMediaId().toByteArray()))
.toList());
} catch (BackupFailedZkAuthenticationException e) {
return JdkFlowAdapter.publisherToFlowPublisher(Mono.just(DeleteMediaResponse
.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))
.build()));
}
return JdkFlowAdapter.publisherToFlowPublisher(deleteItems
.map(storageDescriptor -> DeleteMediaResponse.newBuilder()
.setMediaId(ByteString.copyFrom(storageDescriptor.key()))
.setCdn(storageDescriptor.cdn()).build()));
.setDeletedItem(DeleteMediaItem.newBuilder()
.setMediaId(ByteString.copyFrom(storageDescriptor.key()))
.setCdn(storageDescriptor.cdn()))
.build()));
}
private CompletableFuture<AuthenticatedBackupUser> authenticateBackupUserAsync(final SignedPresentation signedPresentation) {
@Override
public Throwable mapException(final Throwable throwable) {
return switch (throwable) {
case BackupInvalidArgumentException e -> GrpcExceptions.invalidArguments(e.getMessage());
case BackupPermissionException e -> GrpcExceptions.badAuthentication(e.getMessage());
case BackupWrongCredentialTypeException e -> GrpcExceptions.badAuthentication(e.getMessage());
default -> throwable;
};
}
private AuthenticatedBackupUser authenticateBackupUser(final SignedPresentation signedPresentation)
throws BackupFailedZkAuthenticationException {
if (signedPresentation == null) {
throw Status.UNAUTHENTICATED.asRuntimeException();
throw GrpcExceptions.badAuthentication("Missing required signedPresentation");
}
try {
return backupManager.authenticateBackupUserAsync(
return backupManager.authenticateBackupUser(
new BackupAuthCredentialPresentation(signedPresentation.getPresentation().toByteArray()),
signedPresentation.getPresentationSignature().toByteArray(),
RequestAttributesUtil.getUserAgent().orElse(null));
} catch (InvalidInputException e) {
throw Status.UNAUTHENTICATED.withDescription("Could not deserialize presentation").asRuntimeException();
throw GrpcExceptions.badAuthentication("Could not deserialize presentation");
}
}
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)}
*/
private static int fromUnsignedExact(final int i) {
if (i < 0) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid size").asRuntimeException();
throw GrpcExceptions.invalidArguments("integer length too large");
}
return i;
}
@@ -253,7 +339,7 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
try {
return deserializer.deserialize(bytes);
} catch (InvalidInputException | InvalidKeyException e) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid serialization").asRuntimeException();
throw GrpcExceptions.invalidArguments("invalid serialization");
}
}

View File

@@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
import io.grpc.Status;
import io.micrometer.core.instrument.Tag;
import java.time.Clock;
@@ -20,6 +21,7 @@ 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.chat.errors.FailedPrecondition;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
@@ -28,6 +30,12 @@ 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.backup.BackupBadReceiptException;
import org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;
import org.whispersystems.textsecuregcm.backup.BackupMissingIdCommitmentException;
import org.whispersystems.textsecuregcm.backup.BackupNotFoundException;
import org.whispersystems.textsecuregcm.backup.BackupPermissionException;
import org.whispersystems.textsecuregcm.backup.BackupWrongCredentialTypeException;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
@@ -48,7 +56,8 @@ public class BackupsGrpcService extends SimpleBackupsGrpc.BackupsImplBase {
}
@Override
public SetBackupIdResponse setBackupId(SetBackupIdRequest request) throws RateLimitExceededException {
public SetBackupIdResponse setBackupId(SetBackupIdRequest request)
throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {
final Optional<BackupAuthCredentialRequest> messagesCredentialRequest = deserializeWithEmptyPresenceCheck(
BackupAuthCredentialRequest::new,
@@ -67,13 +76,21 @@ public class BackupsGrpcService extends SimpleBackupsGrpc.BackupsImplBase {
return SetBackupIdResponse.getDefaultInstance();
}
public RedeemReceiptResponse redeemReceipt(RedeemReceiptRequest request) {
public RedeemReceiptResponse redeemReceipt(RedeemReceiptRequest request) throws BackupInvalidArgumentException {
final ReceiptCredentialPresentation receiptCredentialPresentation = deserialize(
ReceiptCredentialPresentation::new,
request.getPresentation().toByteArray());
final Account account = authenticatedAccount();
backupAuthManager.redeemReceipt(account, receiptCredentialPresentation);
return RedeemReceiptResponse.getDefaultInstance();
final RedeemReceiptResponse.Builder builder = RedeemReceiptResponse.newBuilder();
try {
backupAuthManager.redeemReceipt(account, receiptCredentialPresentation);
builder.setSuccess(Empty.getDefaultInstance());
} catch (BackupBadReceiptException e) {
builder.setInvalidReceipt(FailedPrecondition.newBuilder().setDescription(e.getMessage()).build());
} catch (BackupMissingIdCommitmentException e) {
builder.setAccountMissingCommitment(FailedPrecondition.newBuilder().build());
}
return builder.build();
}
@Override
@@ -88,34 +105,51 @@ public class BackupsGrpcService extends SimpleBackupsGrpc.BackupsImplBase {
throw Status.INVALID_ARGUMENT.withDescription(e.getMessage()).asRuntimeException();
}
final Account account = authenticatedAccount();
final List<BackupAuthManager.Credential> messageCredentials =
backupAuthManager.getBackupAuthCredentials(
account,
BackupCredentialType.MESSAGES,
redemptionRange);
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MESSAGES, messageCredentials.size());
try {
final List<BackupAuthManager.Credential> messageCredentials =
backupAuthManager.getBackupAuthCredentials(
account,
BackupCredentialType.MESSAGES,
redemptionRange);
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MESSAGES, messageCredentials.size());
final List<BackupAuthManager.Credential> mediaCredentials =
backupAuthManager.getBackupAuthCredentials(
account,
BackupCredentialType.MEDIA,
redemptionRange);
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MEDIA, mediaCredentials.size());
final List<BackupAuthManager.Credential> mediaCredentials =
backupAuthManager.getBackupAuthCredentials(
account,
BackupCredentialType.MEDIA,
redemptionRange);
backupMetrics.updateGetCredentialCounter(platformTag, BackupCredentialType.MEDIA, mediaCredentials.size());
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();
return GetBackupAuthCredentialsResponse.newBuilder()
.setCredentials(GetBackupAuthCredentialsResponse.Credentials.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())
.build();
} catch (BackupNotFoundException _) {
// Return an empty response to indicate that the authenticated account had no associated blinded backup-id
return GetBackupAuthCredentialsResponse.getDefaultInstance();
}
}
@Override
public Throwable mapException(final Throwable throwable) {
return switch (throwable) {
case BackupInvalidArgumentException e -> GrpcExceptions.invalidArguments(e.getMessage());
case BackupPermissionException e -> GrpcExceptions.badAuthentication(e.getMessage());
case BackupWrongCredentialTypeException e -> GrpcExceptions.badAuthentication(e.getMessage());
default -> throwable;
};
}
private Account authenticatedAccount() {

View File

@@ -79,6 +79,20 @@ public class GrpcExceptions {
.build());
}
/// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always
/// possible to check this constraint without communicating with the chat server. This always represents a client bug
/// or out of date client.
///
/// @param message Additional context about the constraint violation
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException invalidArguments(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
.addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)
.build());
}
/// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always
/// possible to check this constraint without communicating with the chat server. This always represents a client bug
/// or out of date client.

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.mappers;
import io.dropwizard.jersey.errors.ErrorMessage;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import org.whispersystems.textsecuregcm.backup.BackupBadReceiptException;
import org.whispersystems.textsecuregcm.backup.BackupException;
import org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException;
import org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;
import org.whispersystems.textsecuregcm.backup.BackupMissingIdCommitmentException;
import org.whispersystems.textsecuregcm.backup.BackupNotFoundException;
import org.whispersystems.textsecuregcm.backup.BackupPermissionException;
import org.whispersystems.textsecuregcm.backup.BackupWrongCredentialTypeException;
public class BackupExceptionMapper implements ExceptionMapper<BackupException> {
@Override
public Response toResponse(final BackupException exception) {
final Response.Status status = (switch (exception) {
case BackupNotFoundException _ -> Response.Status.NOT_FOUND;
case BackupInvalidArgumentException _, BackupBadReceiptException _ -> Response.Status.BAD_REQUEST;
case BackupPermissionException _ -> Response.Status.FORBIDDEN;
case BackupMissingIdCommitmentException _ -> Response.Status.CONFLICT;
case BackupWrongCredentialTypeException _,
BackupFailedZkAuthenticationException _ -> Response.Status.UNAUTHORIZED;
default -> Response.Status.INTERNAL_SERVER_ERROR;
});
final WebApplicationException wae =
new WebApplicationException(exception.getMessage(), exception, Response.status(status).build());
return Response
.fromResponse(wae.getResponse())
.type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorMessage(wae.getResponse().getStatus(), wae.getLocalizedMessage())).build();
}
}

View File

@@ -11,7 +11,9 @@ package org.signal.chat.backup;
import "google/protobuf/empty.proto";
import "org/signal/chat/common.proto";
import "org/signal/chat/errors.proto";
import "org/signal/chat/require.proto";
import "org/signal/chat/tag.proto";
/**
* Service for backup operations that require account authentication.
@@ -19,16 +21,6 @@ import "org/signal/chat/require.proto";
* Most actual backup operations operate on the backup-id and cannot be linked
* to the caller's account, but setting up anonymous credentials and changing
* backup tier requires account authentication.
*
* All rpcs on this service may return these errors. rpc specific errors
* documented on the individual rpc.
*
* errors:
* UNAUTHENTICATED Authentication failed or the account does not exist
* INVALID_ARGUMENT The request did not meet a documented requirement
* RESOURCE_EXHAUSTED Rate limit exceeded. A retry-after header containing an
* ISO8601 duration string will be present in the response
* trailers.
*/
service Backups {
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
@@ -44,9 +36,7 @@ service Backups {
* from a recoverable secret.
*
* At least one of the credential types must be set on the request.
*
* errors:
* PERMISSION_DENIED: This account is not currently eligible for backups
* Only the primary device can set a blinded backup-id.
*/
rpc SetBackupId(SetBackupIdRequest) returns (SetBackupIdResponse) {}
@@ -57,9 +47,6 @@ service Backups {
* After successful redemption, subsequent requests to
* GetBackupAuthCredentials will return credentials with the level on the
* provided receipt until the expiration time on the receipt.
*
* errors:
* ABORTED: The target account does not have a backup-id commitment
*/
rpc RedeemReceipt(RedeemReceiptRequest) returns (RedeemReceiptResponse) {}
@@ -79,9 +66,6 @@ service Backups {
* Clients must validate the receipt level on the credential matches a known
* receipt level before using it.
*
* errors:
* NOT_FOUND: Could not find an existing blinded backup id associated with the
* account.
*/
rpc GetBackupAuthCredentials(GetBackupAuthCredentialsRequest) returns (GetBackupAuthCredentialsResponse) {}
}
@@ -103,6 +87,7 @@ message SetBackupIdRequest {
*/
bytes media_backup_auth_credential_request = 2;
}
message SetBackupIdResponse {}
@@ -112,7 +97,25 @@ message RedeemReceiptRequest {
*/
bytes presentation = 1;
}
message RedeemReceiptResponse {}
message RedeemReceiptResponse {
oneof outcome {
/**
* The receipt was successfully redeemed
*/
google.protobuf.Empty success = 1;
/**
* The target account does not have a backup-id commitment
*/
errors.FailedPrecondition account_missing_commitment = 2 [(tag.reason) = "account_missing_commitment"];
/**
* The provided receipt presentation was malformed or expired
*/
errors.FailedPrecondition invalid_receipt = 3 [(tag.reason) = "invalid_receipt"];
}
}
message GetBackupAuthCredentialsRequest {
/**
@@ -130,21 +133,27 @@ message GetBackupAuthCredentialsRequest {
}
message GetBackupAuthCredentialsResponse {
/**
* The requested message backup ZkCredentials indexed by the start of their
* validity period. The smallest key should be for the requested
* redemption_start, the largest for the requested the requested
* redemption_end.
*/
map<int64, common.ZkCredential> message_credentials = 1;
message Credentials {
/**
* The requested message backup ZkCredentials indexed by the start of their
* validity period. The smallest key should be for the requested
* redemption_start, the largest for the requested redemption_end.
*/
map<int64, common.ZkCredential> message_credentials = 1;
/**
* The requested media backup ZkCredentials indexed by the start of their
* validity period. The smallest key should be for the requested
* redemption_start, the largest for the requested redemption_end.
*/
map<int64, common.ZkCredential> media_credentials = 2;
}
/**
* The requested media backup ZkCredentials indexed by the start of their
* validity period. The smallest key should be for the requested
* redemption_start, the largest for the requested the requested
* redemption_end.
* The requested credentials. If absent, there was no existing blinded
* backup id associated with the provided account.
*/
map<int64, common.ZkCredential> media_credentials = 2;
Credentials credentials = 1;
}
/**
@@ -167,21 +176,6 @@ message GetBackupAuthCredentialsResponse {
* - a signature of that presentation using the private key of a key pair
* previously set with SetPublicKey.
*
* All RPCs on this service may return these errors. RPC specific errors
* documented on the individual RPC.
*
* errors:
* UNAUTHENTICATED Either the presentation was missing, the credential was
* expired, presentation verification failed, the signature
* was incorrect, there was no committed public key for the
* corresponding backup id, or the request was made on a
* non-anonymous channel.
* PERMISSION_DENIED The credential does not have permission to perform the
* requested action.
* RESOURCE_EXHAUSTED Rate limit exceeded. A retry-after header containing an
* ISO8601 duration string will be present in the response
* trailers.
* INVALID_ARGUMENT The request did not meet a documented requirement
*/
service BackupsAnonymous {
option (require.auth) = AUTH_ONLY_ANONYMOUS;
@@ -205,9 +199,6 @@ service BackupsAnonymous {
* Permanently set the public key of an ED25519 key-pair for the backup-id.
* All requests (including this one!) must sign their BackupAuthCredential
* presentations with the private key corresponding to the provided public key.
*
* Trying to set a public key when a different one is already set will return
* an UNAUTHENTICATED error.
*/
rpc SetPublicKey(SetPublicKeyRequest) returns (SetPublicKeyResponse) {}
@@ -220,10 +211,6 @@ service BackupsAnonymous {
/**
* Retrieve an upload form that can be used to perform a resumable upload
*
* Trying to request an upload form larger than the maximum supported upload
* size will return a PRECONDITION_FAILED error. The maximum upload size is
* subject to change.
*/
rpc GetUploadForm(GetUploadFormRequest) returns (GetUploadFormResponse) {}
@@ -285,76 +272,137 @@ message SetPublicKeyRequest {
*/
bytes public_key = 2;
}
message SetPublicKeyResponse {}
message SetPublicKeyResponse {
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*
* This may also be returned if there was an existing public key and the
* provided public key did not match.
*/
errors.FailedZkAuthentication failed_authentication = 1 [(tag.reason) = "failed_authentication"];
}
message GetCdnCredentialsRequest {
SignedPresentation signed_presentation = 1;
int32 cdn = 2;
}
message GetCdnCredentialsResponse {
/**
* Headers to include with requests to the read from the backup CDN. Includes
* time limited read-only credentials.
*/
map<string, string> headers = 1;
message CdnCredentials {
map<string, string> headers = 1;
}
oneof outcome {
/**
* Headers to include with requests to the read from the backup CDN. Includes
* time limited read-only credentials.
*/
CdnCredentials cdn_credentials = 1;
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*/
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message GetSvrBCredentialsRequest {
SignedPresentation signed_presentation = 1;
}
message GetSvrBCredentialsResponse {
/**
* A username that can be presented to authenticate with SVRB
*/
string username = 1;
/**
* A password that can be presented to authenticate with SVRB
*/
string password = 2;
message GetSvrBCredentialsResponse {
message SvrBCredentials {
/**
* A username that can be presented to authenticate with SVRB
*/
string username = 1;
/**
* A password that can be presented to authenticate with SVRB
*/
string password = 2;
}
oneof outcome {
SvrBCredentials svrb_credentials = 1;
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*/
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message GetBackupInfoRequest {
SignedPresentation signed_presentation = 1;
}
message GetBackupInfoResponse {
/**
* The base directory of your backup data on the cdn. The message backup can
* be found in the returned cdn at /backup_dir/backup_name and stored media can
* be found at /backup_dir/media_dir/media_id
*/
string backup_dir = 1;
message BackupInfo {
/**
* The base directory of your backup data on the cdn. The message backup can
* be found in the returned cdn at /backup_dir/backup_name and stored media can
* be found at /backup_dir/media_dir/media_id
*/
string backup_dir = 1;
/**
* The prefix path component for media objects on a cdn. Stored media for a
* media_id can be found at /backup_dir/media_dir/media_id, where the media_id
* is encoded in unpadded url-safe base64.
*/
string media_dir = 2;
/**
* The prefix path component for media objects on a cdn. Stored media for a
* media_id can be found at /backup_dir/media_dir/media_id, where the media_id
* is encoded in unpadded url-safe base64.
*/
string media_dir = 2;
/**
* The CDN type where the message backup is stored. Media may be stored
* elsewhere. If absent, no message backup currently exists.
*/
optional int32 cdn = 3;
/**
* The CDN type where the message backup is stored. Media may be stored
* elsewhere. If absent, no message backup currently exists.
*/
optional int32 cdn = 3;
/**
* The name of the most recent message backup on the cdn. The backup is at
* /backup_dir/backup_name. If absent, no message backup currently exists.
*/
optional string backup_name = 4;
/**
* The name of the most recent message backup on the cdn. The backup is at
* /backup_dir/backup_name. If absent, no message backup currently exists.
*/
optional string backup_name = 4;
/**
* The amount of space used to store media
*/
uint64 used_space = 5;
/**
* The amount of space used to store media
*/
uint64 used_space = 5;
}
oneof outcome {
BackupInfo backup_info = 1;
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*/
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message RefreshRequest {
SignedPresentation signed_presentation = 1;
}
message RefreshResponse {
SignedPresentation signed_presentation = 1;
oneof outcome {
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*/
errors.FailedZkAuthentication failed_authentication = 1 [(tag.reason) = "failed_authentication"];
}
}
message GetUploadFormRequest {
@@ -382,26 +430,46 @@ message GetUploadFormRequest {
}
}
message GetUploadFormResponse {
/**
* Indicates the CDN type. 3 indicates resumable uploads using TUS
*/
int32 cdn = 1;
message UploadForm {
/**
* Indicates the CDN type. 3 indicates resumable uploads using TUS
*/
int32 cdn = 1;
/**
* The location within the specified cdn where the finished upload can be found
*/
string key = 2;
/**
* The location within the specified cdn where the finished upload can be found
*/
string key = 2;
/**
* A map of headers to include with all upload requests. Potentially contains
* time-limited upload credentials
*/
map<string, string> headers = 3;
/**
* A map of headers to include with all upload requests. Potentially contains
* time-limited upload credentials
*/
map<string, string> headers = 3;
/**
* The URL to upload to with the appropriate protocol
*/
string signed_upload_location = 4;
/**
* The URL to upload to with the appropriate protocol
*/
string signed_upload_location = 4;
}
oneof outcome {
UploadForm upload_form = 1;
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*/
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
/**
* The request size was larger than the maximum supported upload size. The
* maximum upload size is subject to change.
*/
errors.FailedPrecondition exceeds_max_upload_length = 3 [(tag.reason) = "oversize_upload"];
}
}
message CopyMediaItem {
@@ -468,20 +536,28 @@ message CopyMediaResponse {
*/
CopySuccess success = 2;
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*/
errors.FailedZkAuthentication failed_authentication = 3 [(tag.reason) = "failed_authentication"];
/**
* The source object was not found
*/
SourceNotFound source_not_found = 3;
SourceNotFound source_not_found = 4;
/**
* The provided object length was incorrect
*/
WrongSourceLength wrong_source_length = 4;
WrongSourceLength wrong_source_length = 5;
/**
* All media capacity has been consumed. Free some space to continue.
*/
OutOfSpace out_of_space = 5;
OutOfSpace out_of_space = 6;
}
}
@@ -514,36 +590,61 @@ message ListMediaResponse {
uint64 length = 3;
}
/**
* A page of media objects stored for this backup ID
*/
repeated ListEntry page = 1;
message ListResult {
/**
* The base directory of the backup data on the cdn. The stored media can be
* found at /backup_dir/media_dir/media_id, where the media_id is encoded with
* unpadded url-safe base64.
*/
string backup_dir = 2;
/**
* A page of media objects stored for this backup ID
*/
repeated ListEntry page = 1;
/**
* The prefix path component for the media objects. The stored media for
* media_id can be found at /backup_dir/media_dir/media_id, where the media_id
* is encoded with unpadded url-safe base64.
*/
string media_dir = 3;
/**
* The base directory of the backup data on the cdn. The stored media can be
* found at /backup_dir/media_dir/media_id, where the media_id is encoded with
* unpadded url-safe base64.
*/
string backup_dir = 2;
/**
* If set, the cursor value to pass to the next list request to continue
* listing. If absent, all objects have been listed
*/
optional string cursor = 4;
/**
* The prefix path component for the media objects. The stored media for
* media_id can be found at /backup_dir/media_dir/media_id, where the media_id
* is encoded with unpadded url-safe base64.
*/
string media_dir = 3;
/**
* If set, the cursor value to pass to the next list request to continue
* listing. If absent, all objects have been listed
*/
optional string cursor = 4;
}
oneof outcome {
ListResult list_result = 1;
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*/
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}
message DeleteAllRequest {
SignedPresentation signed_presentation = 1;
}
message DeleteAllResponse {}
message DeleteAllResponse {
oneof outcome {
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*/
errors.FailedZkAuthentication failed_authentication = 1 [(tag.reason) = "failed_authentication"];
}
}
message DeleteMediaItem {
/**
@@ -564,13 +665,15 @@ message DeleteMediaRequest {
}
message DeleteMediaResponse {
/**
* The backup cdn where the media object was stored
*/
int32 cdn = 1;
oneof outcome {
DeleteMediaItem deleted_item = 1;
/**
* The media_id of the object that was successfully deleted
*/
bytes media_id = 3;
/**
* The provided backup auth credential presentation could not be
* authenticated. Either, the presentation could not be verified, or
* the public key signature was invalid, or there is no backup associated
* with the backup-id in the presentation.
*/
errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = "failed_authentication"];
}
}

View File

@@ -19,10 +19,19 @@ message NotFound {}
* met. For example, if there was a request to update foo, but foo had not been
* set, this would be an appropriate error.
*/
message FailedPrecondition {
/**
* An optional description indicating what precondition failed.
*/
string description = 1;
}
/**
* Response message that authentication via an anonymous credential failed.
*/
message FailedZkAuthentication {
/**
* An optional description with additional information about the failure.
*/
string description = 1;
}

View File

@@ -6,7 +6,6 @@
package org.whispersystems.textsecuregcm.backup;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.mockito.ArgumentMatchers.any;
@@ -21,8 +20,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -107,7 +104,7 @@ public class BackupAuthManagerTest {
}
@Test
void commitBackupId() throws RateLimitExceededException {
void commitBackupId() throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {
final BackupAuthManager authManager = create();
final Account account = mock(Account.class);
@@ -158,15 +155,13 @@ public class BackupAuthManagerTest {
linkedDevice(),
Optional.of(backupAuthTestUtil.getRequest(messagesBackupKey, aci)),
Optional.of(backupAuthTestUtil.getRequest(mediaBackupKey, aci)));
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(commit)
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.PERMISSION_DENIED);
assertThatExceptionOfType(BackupPermissionException.class)
.isThrownBy(commit);
}
@CartesianTest
void paidTierCredentialViaConfiguration(@CartesianTest.Enum final BackupCredentialType credentialType)
throws VerificationFailedException {
throws VerificationFailedException, BackupNotFoundException {
final BackupAuthManager authManager = create(BackupLevel.PAID, rateLimiter(aci, false, false));
final byte[] backupKey = switch (credentialType) {
@@ -196,7 +191,7 @@ public class BackupAuthManagerTest {
@CartesianTest
void getBackupAuthCredentials(@CartesianTest.Enum final BackupLevel backupLevel,
@CartesianTest.Enum final BackupCredentialType credentialType) {
@CartesianTest.Enum final BackupCredentialType credentialType) throws BackupNotFoundException {
final BackupAuthManager authManager = create();
@@ -217,16 +212,14 @@ public class BackupAuthManagerTest {
final Account account = new MockAccountBuilder().build();
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() ->
authManager.getBackupAuthCredentials(account, credentialType, range(Duration.ofDays(1))))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.NOT_FOUND);
assertThatExceptionOfType(BackupNotFoundException.class)
.isThrownBy(() -> authManager.getBackupAuthCredentials(account, credentialType, range(Duration.ofDays(1))));
}
@CartesianTest
void getReceiptCredentials(@CartesianTest.Enum final BackupLevel backupLevel,
@CartesianTest.Enum final BackupCredentialType credentialType) throws VerificationFailedException {
@CartesianTest.Enum final BackupCredentialType credentialType)
throws VerificationFailedException, BackupNotFoundException {
final BackupAuthManager authManager = create();
final byte[] backupKey = switch (credentialType) {
@@ -259,7 +252,7 @@ public class BackupAuthManagerTest {
}
@Test
void expiringBackupPayment() throws VerificationFailedException {
void expiringBackupPayment() throws VerificationFailedException, BackupNotFoundException {
clock.pin(Instant.ofEpochSecond(1));
final Instant day4 = Instant.EPOCH.plus(Duration.ofDays(4));
@@ -292,7 +285,7 @@ public class BackupAuthManagerTest {
}
@Test
void expiredBackupPayment() {
void expiredBackupPayment() throws BackupNotFoundException {
final Instant day1 = Instant.EPOCH.plus(Duration.ofDays(1));
final Instant day2 = Instant.EPOCH.plus(Duration.ofDays(2));
final Instant day3 = Instant.EPOCH.plus(Duration.ofDays(3));
@@ -335,7 +328,8 @@ public class BackupAuthManagerTest {
@Test
void redeemReceipt() throws InvalidInputException, VerificationFailedException {
void redeemReceipt()
throws InvalidInputException, VerificationFailedException, BackupInvalidArgumentException, BackupMissingIdCommitmentException, BackupBadReceiptException {
final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1));
final BackupAuthManager authManager = create();
final Account account = new MockAccountBuilder()
@@ -358,15 +352,13 @@ public class BackupAuthManagerTest {
clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));
when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci)))
.thenReturn(CompletableFuture.completedFuture(true));
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() ->
authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.ABORTED);
assertThatExceptionOfType(BackupMissingIdCommitmentException.class)
.isThrownBy(() -> authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)));
}
@Test
void mergeRedemptions() throws InvalidInputException, VerificationFailedException {
void mergeRedemptions()
throws InvalidInputException, VerificationFailedException, BackupInvalidArgumentException, BackupMissingIdCommitmentException, BackupBadReceiptException {
final Instant newExpirationTime = Instant.EPOCH.plus(Duration.ofDays(1));
final Instant existingExpirationTime = Instant.EPOCH.plus(Duration.ofDays(1)).plus(Duration.ofSeconds(1));
@@ -396,10 +388,8 @@ public class BackupAuthManagerTest {
final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1));
clock.pin(expirationTime.plus(Duration.ofSeconds(1)));
final BackupAuthManager authManager = create();
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), receiptPresentation(3, expirationTime)))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
assertThatExceptionOfType(BackupBadReceiptException.class)
.isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), receiptPresentation(3, expirationTime)));
verifyNoInteractions(accountsManager);
verifyNoInteractions(redeemedReceiptsManager);
}
@@ -410,11 +400,8 @@ public class BackupAuthManagerTest {
final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1));
clock.pin(expirationTime.plus(Duration.ofSeconds(1)));
final BackupAuthManager authManager = create();
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() ->
authManager.redeemReceipt(mock(Account.class), receiptPresentation(level, expirationTime)))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
assertThatExceptionOfType(BackupBadReceiptException.class)
.isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), receiptPresentation(level, expirationTime)));
verifyNoInteractions(accountsManager);
verifyNoInteractions(redeemedReceiptsManager);
}
@@ -423,10 +410,8 @@ public class BackupAuthManagerTest {
void redeemInvalidPresentation() throws InvalidInputException, VerificationFailedException {
final BackupAuthManager authManager = create();
final ReceiptCredentialPresentation invalid = receiptPresentation(ServerSecretParams.generate(), 3L, Instant.EPOCH);
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), invalid))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
assertThatExceptionOfType(BackupBadReceiptException.class)
.isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), invalid));
verifyNoInteractions(accountsManager);
verifyNoInteractions(redeemedReceiptsManager);
}
@@ -444,10 +429,8 @@ public class BackupAuthManagerTest {
when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci)))
.thenReturn(CompletableFuture.completedFuture(false));
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
assertThatExceptionOfType(BackupBadReceiptException.class)
.isThrownBy(() -> authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)));
verifyNoInteractions(accountsManager);
}
@@ -544,10 +527,8 @@ public class BackupAuthManagerTest {
authManager.commitBackupId(account, primaryDevice(), newMessagesCredential, newMediaCredential);
if (messageChange == CredentialChangeType.NO_UPDATE && mediaChange == CredentialChangeType.NO_UPDATE) {
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(commit)
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.Code.INVALID_ARGUMENT);
assertThatExceptionOfType(BackupInvalidArgumentException.class)
.isThrownBy(commit);
} else if (expectRateLimit) {
assertThatExceptionOfType(RateLimitExceededException.class).isThrownBy(commit);
} else {

View File

@@ -14,6 +14,7 @@ import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Assertions;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;
@@ -76,6 +77,10 @@ public class BackupAuthTestUtil {
});
final RedemptionRange redemptionRange;
redemptionRange = RedemptionRange.inclusive(clock, redemptionStart, redemptionEnd);
return issuer.getBackupAuthCredentials(account, credentialType, redemptionRange);
try {
return issuer.getBackupAuthCredentials(account, credentialType, redemptionRange);
} catch (BackupNotFoundException e) {
return Assertions.fail("Backup credential request not found even though we set one");
}
}
}

View File

@@ -24,8 +24,6 @@ import static org.mockito.Mockito.when;
import static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;
import io.dropwizard.util.DataSize;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
@@ -184,11 +182,7 @@ public class BackupManagerTest {
() -> BackupManager.checkBackupLevel(backupUser, requiredLevel);
if (expectException) {
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(checkBackupLevel)
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.Code.PERMISSION_DENIED);
assertThatExceptionOfType(BackupPermissionException.class).isThrownBy(checkBackupLevel);
} else {
assertThatNoException().isThrownBy(checkBackupLevel);
}
@@ -212,11 +206,7 @@ public class BackupManagerTest {
() -> BackupManager.checkBackupCredentialType(backupUser, requiredCredentialType);
if (expectException) {
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(checkCredentialType)
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.Code.UNAUTHENTICATED);
assertThatExceptionOfType(BackupWrongCredentialTypeException.class).isThrownBy(checkCredentialType);
} else {
assertThatNoException().isThrownBy(checkCredentialType);
}
@@ -224,7 +214,7 @@ public class BackupManagerTest {
@ParameterizedTest
@EnumSource
public void createBackup(final BackupLevel backupLevel) {
public void createBackup(final BackupLevel backupLevel) throws BackupException {
final Instant now = Instant.ofEpochSecond(Duration.ofDays(1).getSeconds());
testClock.pin(now);
@@ -253,9 +243,8 @@ public class BackupManagerTest {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, backupLevel);
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.createMessageBackupUploadDescriptor(backupUser))
.matches(exception -> exception.getStatus().getCode() == Status.UNAUTHENTICATED.getCode());
assertThatExceptionOfType(BackupWrongCredentialTypeException.class)
.isThrownBy(() -> backupManager.createMessageBackupUploadDescriptor(backupUser));
}
@Test
@@ -270,26 +259,20 @@ public class BackupManagerTest {
@Test
public void createTemporaryMediaAttachmentWrongTier() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.FREE);
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.Code.PERMISSION_DENIED);
assertThatExceptionOfType(BackupPermissionException.class)
.isThrownBy(() -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser));
}
@Test
public void createTemporaryMediaAttachmentWrongCredentialType() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.Code.UNAUTHENTICATED);
assertThatExceptionOfType(BackupWrongCredentialTypeException.class)
.isThrownBy(() -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser));
}
@ParameterizedTest
@EnumSource
public void ttlRefresh(final BackupLevel backupLevel) {
public void ttlRefresh(final BackupLevel backupLevel) throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, backupLevel);
final Instant tstart = Instant.ofEpochSecond(1).plus(Duration.ofDays(1));
@@ -311,7 +294,7 @@ public class BackupManagerTest {
@ParameterizedTest
@EnumSource
public void createBackupRefreshesTtl(final BackupLevel backupLevel) {
public void createBackupRefreshesTtl(final BackupLevel backupLevel) throws BackupException {
final Instant tstart = Instant.ofEpochSecond(1).plus(Duration.ofDays(1));
final Instant tnext = tstart.plus(Duration.ofDays(1));
@@ -340,19 +323,16 @@ public class BackupManagerTest {
final ECKeyPair keyPair = ECKeyPair.generate();
// haven't set a public key yet, but should fail before hitting the database anyway
assertThatExceptionOfType(StatusRuntimeException.class)
assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(
invalidPresentation,
keyPair.getPrivateKey().calculateSignature(invalidPresentation.serialize()),
null))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.UNAUTHENTICATED.getCode());
null));
}
@Test
public void invalidPresentationCorrectSignature() throws VerificationFailedException {
public void invalidPresentationCorrectSignature() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.FREE, backupKey, aci);
final BackupAuthCredentialPresentation invalidPresentation = backupAuthTestUtil.getPresentation(
@@ -365,14 +345,11 @@ public class BackupManagerTest {
keyPair.getPrivateKey().calculateSignature(presentation.serialize()),
keyPair.getPublicKey());
assertThatExceptionOfType(StatusRuntimeException.class)
assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(
invalidPresentation,
keyPair.getPrivateKey().calculateSignature(invalidPresentation.serialize()),
null))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.UNAUTHENTICATED.getCode());
null));
}
@Test
@@ -384,15 +361,12 @@ public class BackupManagerTest {
final byte[] signature = keyPair.getPrivateKey().calculateSignature(presentation.serialize());
// haven't set a public key yet
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(presentation, signature, null))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.UNAUTHENTICATED.getCode());
assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(presentation, signature, null));
}
@Test
public void mismatchedPublicKey() throws VerificationFailedException {
public void mismatchedPublicKey() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.FREE, backupKey, aci);
@@ -404,18 +378,15 @@ public class BackupManagerTest {
backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey());
// shouldn't be able to set a different public key
assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() ->
backupManager.setPublicKey(presentation, signature2, keyPair2.getPublicKey()))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.UNAUTHENTICATED.getCode());
assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)
.isThrownBy(() -> backupManager.setPublicKey(presentation, signature2, keyPair2.getPublicKey()));
// should be able to set the same public key again (noop)
backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey());
}
@Test
public void signatureValidation() throws VerificationFailedException {
public void signatureValidation() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.FREE, backupKey, aci);
@@ -427,19 +398,14 @@ public class BackupManagerTest {
wrongSignature[1] += 1;
// shouldn't be able to set a public key with an invalid signature
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.setPublicKey(presentation, wrongSignature, keyPair.getPublicKey()))
.extracting(ex -> ex.getStatus().getCode())
.isEqualTo(Status.UNAUTHENTICATED.getCode());
assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)
.isThrownBy(() -> backupManager.setPublicKey(presentation, wrongSignature, keyPair.getPublicKey()));
backupManager.setPublicKey(presentation, signature, keyPair.getPublicKey());
// shouldn't be able to authenticate with an invalid signature
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(presentation, wrongSignature, null))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.UNAUTHENTICATED.getCode());
assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(presentation, wrongSignature, null));
// correct signature
final AuthenticatedBackupUser user = backupManager.authenticateBackupUser(presentation, signature, null);
@@ -448,7 +414,7 @@ public class BackupManagerTest {
}
@Test
public void credentialExpiration() throws VerificationFailedException {
public void credentialExpiration() throws VerificationFailedException, BackupException {
// credential for 1 day after epoch
testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(1)));
@@ -468,15 +434,12 @@ public class BackupManagerTest {
// should be rejected the day after that
testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(3)));
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature, null))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.UNAUTHENTICATED.getCode());
assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)
.isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature, null));
}
@Test
public void copySuccess() {
public void copySuccess() throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
final CopyResult copied = copy(backupUser);
@@ -493,7 +456,7 @@ public class BackupManagerTest {
}
@Test
public void copyUsageCheckpoints() throws InterruptedException {
public void copyUsageCheckpoints() throws InterruptedException, BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
backupsDb.setMediaUsage(backupUser, new UsageInfo(0, 0)).join();
@@ -512,7 +475,7 @@ public class BackupManagerTest {
.thenReturn(slow);
final ArrayBlockingQueue<CopyResult> copyResults = new ArrayBlockingQueue<>(100);
final CompletableFuture<Void> future = backupManager
.copyToBackup(backupUser, toCopy)
.copyToBackup(backupManager.getCopyQuota(backupUser, toCopy))
.doOnNext(copyResults::add).then().toFuture();
for (int i = 0; i < slowIndex; i++) {
@@ -540,7 +503,7 @@ public class BackupManagerTest {
}
@Test
public void copyFailure() {
public void copyFailure() throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
assertThat(copyError(backupUser, new SourceObjectNotFoundException()).outcome())
.isEqualTo(CopyResult.Outcome.SOURCE_NOT_FOUND);
@@ -552,7 +515,7 @@ public class BackupManagerTest {
}
@Test
public void copyPartialSuccess() {
public void copyPartialSuccess() throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
final List<CopyParameters> toCopy = List.of(
new CopyParameters(3, "success", 100, COPY_ENCRYPTION_PARAM, TestRandomUtil.nextBytes(15)),
@@ -568,7 +531,7 @@ public class BackupManagerTest {
when(remoteStorageManager.copy(eq(3), eq("badlength"), eq(300), any(), any()))
.thenReturn(CompletableFuture.failedFuture(new InvalidLengthException("")));
final List<CopyResult> results = backupManager.copyToBackup(backupUser, toCopy)
final List<CopyResult> results = backupManager.copyToBackup(backupManager.getCopyQuota(backupUser, toCopy))
.collectList().block();
assertThat(results).hasSize(3);
@@ -587,15 +550,11 @@ public class BackupManagerTest {
public void copyWrongCredentialType() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> copy(backupUser))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.Code.UNAUTHENTICATED);
assertThatExceptionOfType(BackupWrongCredentialTypeException.class).isThrownBy(() -> copy(backupUser));
}
@Test
public void quotaEnforcementNoRecalculation() {
public void quotaEnforcementNoRecalculation() throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
verifyNoInteractions(remoteStorageManager);
@@ -612,7 +571,7 @@ public class BackupManagerTest {
}
@Test
public void quotaEnforcementRecalculation() {
public void quotaEnforcementRecalculation() throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
final String backupMediaPrefix = "%s/%s/".formatted(backupUser.backupDir(), backupUser.mediaDir());
@@ -642,7 +601,7 @@ public class BackupManagerTest {
public void quotaEnforcement(
@CartesianTest.Values(booleans = {true, false}) boolean hasSpaceBeforeRecalc,
@CartesianTest.Values(booleans = {true, false}) boolean hasSpaceAfterRecalc,
@CartesianTest.Values(booleans = {true, false}) boolean doesReaclc) {
@CartesianTest.Values(booleans = {true, false}) boolean doesReaclc) throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
final String backupMediaPrefix = "%s/%s/".formatted(backupUser.backupDir(), backupUser.mediaDir());
@@ -701,7 +660,7 @@ public class BackupManagerTest {
@ParameterizedTest
@ValueSource(strings = {"", "cursor"})
public void list(final String cursorVal) {
public void list(final String cursorVal) throws BackupException {
final Optional<String> cursor = Optional.of(cursorVal).filter(StringUtils::isNotBlank);
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);
final String backupMediaPrefix = "%s/%s/".formatted(backupUser.backupDir(), backupUser.mediaDir());
@@ -724,7 +683,7 @@ public class BackupManagerTest {
}
@Test
public void deleteEntireBackup() {
public void deleteEntireBackup() throws BackupException {
final AuthenticatedBackupUser original = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);
testClock.pin(Instant.ofEpochSecond(10));
@@ -762,7 +721,7 @@ public class BackupManagerTest {
}
@Test
public void delete() {
public void delete() throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
final byte[] mediaId = TestRandomUtil.nextBytes(16);
final String backupMediaKey = "%s/%s/%s".formatted(
@@ -786,10 +745,8 @@ public class BackupManagerTest {
public void deleteWrongCredentialType() {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);
final byte[] mediaId = TestRandomUtil.nextBytes(16);
assertThatThrownBy(() ->
backupManager.deleteMedia(backupUser, List.of(new BackupManager.StorageDescriptor(5, mediaId))).then().block())
.isInstanceOf(StatusRuntimeException.class)
.matches(e -> ((StatusRuntimeException) e).getStatus().getCode() == Status.UNAUTHENTICATED.getCode());
assertThatExceptionOfType(BackupWrongCredentialTypeException.class)
.isThrownBy(() -> backupManager.deleteMedia(backupUser, List.of(new BackupManager.StorageDescriptor(5, mediaId))).then().block());
}
@Test
@@ -797,14 +754,12 @@ public class BackupManagerTest {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
final BackupManager.StorageDescriptor sd = new BackupManager.StorageDescriptor(4, TestRandomUtil.nextBytes(15));
when(remoteStorageManager.cdnNumber()).thenReturn(5);
assertThatThrownBy(() ->
backupManager.deleteMedia(backupUser, List.of(sd)).then().block())
.isInstanceOf(StatusRuntimeException.class)
.matches(e -> ((StatusRuntimeException) e).getStatus().getCode() == Status.INVALID_ARGUMENT.getCode());
assertThatThrownBy(() -> backupManager.deleteMedia(backupUser, List.of(sd)).then().toFuture().join())
.hasCauseInstanceOf(BackupInvalidArgumentException.class);
}
@Test
public void deleteUsageCheckpoints() throws InterruptedException {
public void deleteUsageCheckpoints() throws InterruptedException, BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA,
BackupLevel.PAID);
@@ -849,7 +804,7 @@ public class BackupManagerTest {
}
@Test
public void deletePartialFailure() {
public void deletePartialFailure() throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
final List<BackupManager.StorageDescriptor> descriptors = new ArrayList<>();
@@ -887,7 +842,7 @@ public class BackupManagerTest {
}
@Test
public void alreadyDeleted() {
public void alreadyDeleted() throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);
final byte[] mediaId = TestRandomUtil.nextBytes(16);
final String backupMediaKey = "%s/%s/%s".formatted(
@@ -907,7 +862,7 @@ public class BackupManagerTest {
}
@Test
public void listExpiredBackups() {
public void listExpiredBackups() throws BackupException {
final List<AuthenticatedBackupUser> backupUsers = IntStream.range(0, 10)
.mapToObj(_ -> backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID))
.toList();
@@ -943,7 +898,7 @@ public class BackupManagerTest {
}
@Test
public void listExpiredBackupsByTier() {
public void listExpiredBackupsByTier() throws BackupException {
final byte[] backupId = TestRandomUtil.nextBytes(16);
// refreshed media timestamp at t=5
@@ -971,7 +926,7 @@ public class BackupManagerTest {
@ParameterizedTest
@EnumSource(mode = EnumSource.Mode.INCLUDE, names = {"MEDIA", "ALL"})
public void expireBackup(ExpiredBackup.ExpirationType expirationType) {
public void expireBackup(ExpiredBackup.ExpirationType expirationType) throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);
backupManager.createMessageBackupUploadDescriptor(backupUser);
@@ -1005,11 +960,9 @@ public class BackupManagerTest {
if (expirationType == ExpiredBackup.ExpirationType.ALL) {
// should have deleted the db row for the backup
assertThat(CompletableFutureTestUtil.assertFailsWithCause(
StatusRuntimeException.class,
backupsDb.describeBackup(backupUser))
.getStatus().getCode())
.isEqualTo(Status.NOT_FOUND.getCode());
CompletableFutureTestUtil.assertFailsWithCause(
BackupNotFoundException.class,
backupsDb.describeBackup(backupUser));
} else {
// should have deleted all the media, but left the backup descriptor in place
assertThatNoException().isThrownBy(() -> backupsDb.describeBackup(backupUser).join());
@@ -1017,7 +970,7 @@ public class BackupManagerTest {
}
@Test
public void deleteBackupPaginated() {
public void deleteBackupPaginated() throws BackupException {
final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);
backupManager.createMessageBackupUploadDescriptor(backupUser);
@@ -1055,7 +1008,7 @@ public class BackupManagerTest {
@ParameterizedTest
@EnumSource(BackupLevel.class)
void svrbAuthValid(BackupLevel backupLevel) {
void svrbAuthValid(BackupLevel backupLevel) throws BackupException {
testClock.pin(Instant.ofEpochSecond(123));
final AuthenticatedBackupUser backupUser =
backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, backupLevel);
@@ -1072,29 +1025,26 @@ public class BackupManagerTest {
// Can't use MEDIA for svrb auth
final AuthenticatedBackupUser backupUser =
backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, backupLevel);
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> backupManager.generateSvrbAuth(backupUser))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.Code.UNAUTHENTICATED);
assertThatExceptionOfType(BackupWrongCredentialTypeException.class)
.isThrownBy(() -> backupManager.generateSvrbAuth(backupUser));
}
private CopyResult copyError(final AuthenticatedBackupUser backupUser, Throwable copyException) {
private CopyResult copyError(final AuthenticatedBackupUser backupUser, Throwable copyException) throws BackupException {
when(tusCredentialGenerator.generateUpload(any()))
.thenReturn(new BackupUploadDescriptor(3, "def", Collections.emptyMap(), ""));
when(remoteStorageManager.copy(eq(3), eq(COPY_PARAM.sourceKey()), eq(COPY_PARAM.sourceLength()), any(), any()))
.thenReturn(CompletableFuture.failedFuture(copyException));
return backupManager.copyToBackup(backupUser, List.of(COPY_PARAM)).single().block();
return backupManager.copyToBackup(backupManager.getCopyQuota(backupUser, List.of(COPY_PARAM))).single().block();
}
private CopyResult copy(final AuthenticatedBackupUser backupUser) {
private CopyResult copy(final AuthenticatedBackupUser backupUser) throws BackupException {
when(tusCredentialGenerator.generateUpload(any()))
.thenReturn(new BackupUploadDescriptor(3, "def", Collections.emptyMap(), ""));
when(tusCredentialGenerator.generateUpload(any()))
.thenReturn(new BackupUploadDescriptor(3, "def", Collections.emptyMap(), ""));
when(remoteStorageManager.copy(eq(3), eq(COPY_PARAM.sourceKey()), eq(COPY_PARAM.sourceLength()), any(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
return backupManager.copyToBackup(backupUser, List.of(COPY_PARAM)).single().block();
return backupManager.copyToBackup(backupManager.getCopyQuota(backupUser, List.of(COPY_PARAM))).single().block();
}
private static ExpiredBackup expiredBackup(final ExpiredBackup.ExpirationType expirationType,

View File

@@ -8,8 +8,6 @@ package org.whispersystems.textsecuregcm.backup;
import static org.assertj.core.api.Assertions.assertThat;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -212,10 +210,9 @@ public class BackupsDbTest {
backupsDb.finishExpiration(opt.get()).join();
// The backup entry should be gone
assertThat(CompletableFutureTestUtil.assertFailsWithCause(StatusRuntimeException.class,
backupsDb.describeBackup(backupUser(backupId, BackupCredentialType.MEDIA, BackupLevel.PAID)))
.getStatus().getCode())
.isEqualTo(Status.Code.NOT_FOUND);
CompletableFutureTestUtil.assertFailsWithCause(
BackupNotFoundException.class,
backupsDb.describeBackup(backupUser(backupId, BackupCredentialType.MEDIA, BackupLevel.PAID)));
assertThat(expiredBackups.apply(Instant.ofEpochSecond(10))).isEmpty();
}
}

View File

@@ -17,7 +17,6 @@ import static org.mockito.Mockito.when;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import io.grpc.Status;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.Invocation;
import jakarta.ws.rs.client.WebTarget;
@@ -34,7 +33,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.glassfish.jersey.server.ServerProperties;
@@ -68,10 +66,16 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
import org.whispersystems.textsecuregcm.backup.BackupAuthTestUtil;
import org.whispersystems.textsecuregcm.backup.BackupException;
import org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException;
import org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;
import org.whispersystems.textsecuregcm.backup.BackupManager;
import org.whispersystems.textsecuregcm.backup.BackupNotFoundException;
import org.whispersystems.textsecuregcm.backup.BackupPermissionException;
import org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;
import org.whispersystems.textsecuregcm.backup.CopyResult;
import org.whispersystems.textsecuregcm.entities.RemoteAttachment;
import org.whispersystems.textsecuregcm.mappers.BackupExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
@@ -97,6 +101,7 @@ public class ArchiveControllerTest {
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))
.addProvider(new CompletionExceptionMapper())
.addResource(new GrpcStatusRuntimeExceptionMapper())
.addResource(new BackupExceptionMapper())
.addProvider(new RateLimitExceededExceptionMapper())
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
@@ -165,7 +170,7 @@ public class ArchiveControllerTest {
}
@Test
public void setBackupId() throws RateLimitExceededException {
public void setBackupId() throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {
final Response response = resources.getJerseyTest()
.target("v1/archives/backupid")
.request()
@@ -183,7 +188,8 @@ public class ArchiveControllerTest {
}
@Test
public void setBackupIdPartial() throws RateLimitExceededException {
public void setBackupIdPartial()
throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {
final Response response = resources.getJerseyTest()
.target("v1/archives/backupid")
.request()
@@ -302,13 +308,15 @@ public class ArchiveControllerTest {
public static Stream<Arguments> setBackupIdException() {
return Stream.of(
Arguments.of(new RateLimitExceededException(null), 429),
Arguments.of(Status.INVALID_ARGUMENT.withDescription("test").asRuntimeException(), 400)
Arguments.of(new BackupInvalidArgumentException("test"), 400),
Arguments.of(new BackupPermissionException("test"), 403)
);
}
@ParameterizedTest
@MethodSource
public void setBackupIdException(final Exception ex, final int expectedStatus) throws RateLimitExceededException {
public void setBackupIdException(final Exception ex, final int expectedStatus)
throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {
doThrow(ex).when(backupAuthManager).commitBackupId(any(), any(), any(), any());
final Response response = resources.getJerseyTest()
.target("v1/archives/backupid")
@@ -322,7 +330,7 @@ public class ArchiveControllerTest {
}
@Test
public void getCredentials() {
public void getCredentials() throws BackupNotFoundException {
final Instant start = Instant.now().truncatedTo(ChronoUnit.DAYS);
final Instant end = start.plus(Duration.ofDays(1));
final RedemptionRange expectedRange = RedemptionRange.inclusive(Clock.systemUTC(), start, end);
@@ -331,9 +339,12 @@ public class ArchiveControllerTest {
EnumMapUtil.toEnumMap(BackupCredentialType.class, credentialType -> backupAuthTestUtil.getCredentials(
BackupLevel.PAID, backupAuthTestUtil.getRequest(messagesBackupKey, aci), credentialType, start, end));
expectedCredentialsByType.forEach((credentialType, expectedCredentials) ->
for (Map.Entry<BackupCredentialType, List<BackupAuthManager.Credential>> entry : expectedCredentialsByType.entrySet()) {
final BackupCredentialType credentialType = entry.getKey();
final List<BackupAuthManager.Credential> expectedCredentials = entry.getValue();
when(backupAuthManager.getBackupAuthCredentials(any(), eq(credentialType), eq(expectedRange)))
.thenReturn(expectedCredentials));
.thenReturn(expectedCredentials);
}
final ArchiveController.BackupAuthCredentialsResponse credentialResponse = resources.getJerseyTest()
.target("v1/archives/auth")
@@ -385,7 +396,7 @@ public class ArchiveControllerTest {
}
@Test
public void getBackupInfo() throws VerificationFailedException {
public void getBackupInfo() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
@@ -405,13 +416,13 @@ public class ArchiveControllerTest {
}
@Test
public void putMediaBatchSuccess() throws VerificationFailedException {
public void putMediaBatchSuccess() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
final byte[][] mediaIds = new byte[][]{TestRandomUtil.nextBytes(15), TestRandomUtil.nextBytes(15)};
when(backupManager.copyToBackup(any(), any()))
when(backupManager.copyToBackup(any()))
.thenReturn(Flux.just(
new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[0], 1),
new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[1], 1)));
@@ -449,7 +460,7 @@ public class ArchiveControllerTest {
}
@Test
public void putMediaBatchPartialFailure() throws VerificationFailedException {
public void putMediaBatchPartialFailure() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
@@ -457,7 +468,7 @@ public class ArchiveControllerTest {
.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()))
when(backupManager.copyToBackup(any()))
.thenReturn(Flux.just(
new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[0], 1),
new CopyResult(CopyResult.Outcome.SOURCE_NOT_FOUND, mediaIds[1], null),
@@ -508,7 +519,7 @@ public class ArchiveControllerTest {
@Test
public void copyMediaWithNegativeLength() throws VerificationFailedException {
public void copyMediaWithNegativeLength() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
@@ -541,7 +552,7 @@ public class ArchiveControllerTest {
public void list(
@CartesianTest.Values(booleans = {true, false}) final boolean cursorProvided,
@CartesianTest.Values(booleans = {true, false}) final boolean cursorReturned)
throws VerificationFailedException {
throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
@@ -576,7 +587,7 @@ public class ArchiveControllerTest {
}
@Test
public void delete() throws VerificationFailedException {
public void delete() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(BackupLevel.PAID,
messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
@@ -612,7 +623,7 @@ public class ArchiveControllerTest {
@ParameterizedTest
@MethodSource
public void messagesUploadForm(Optional<Long> uploadLength, boolean expectSuccess) throws VerificationFailedException {
public void messagesUploadForm(Optional<Long> uploadLength, boolean expectSuccess) throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
@@ -641,7 +652,7 @@ public class ArchiveControllerTest {
}
@Test
public void mediaUploadForm() throws VerificationFailedException, RateLimitExceededException {
public void mediaUploadForm() throws VerificationFailedException, BackupException, RateLimitExceededException {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
@@ -671,7 +682,7 @@ public class ArchiveControllerTest {
}
@Test
public void readAuth() throws VerificationFailedException {
public void readAuth() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
@@ -689,7 +700,7 @@ public class ArchiveControllerTest {
@Test
public void svrbAuth() throws VerificationFailedException {
public void svrbAuth() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
@@ -706,7 +717,7 @@ public class ArchiveControllerTest {
}
@Test
public void readAuthInvalidParam() throws VerificationFailedException {
public void readAuthInvalidParam() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
Response response = resources.getJerseyTest()
@@ -728,7 +739,7 @@ public class ArchiveControllerTest {
}
@Test
public void deleteEntireBackup() throws VerificationFailedException {
public void deleteEntireBackup() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation =
backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))
@@ -743,7 +754,7 @@ public class ArchiveControllerTest {
}
@Test
public void invalidSourceAttachmentKey() throws VerificationFailedException {
public void invalidSourceAttachmentKey() throws VerificationFailedException, BackupException {
final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
BackupLevel.PAID, messagesBackupKey, aci);
when(backupManager.authenticateBackupUser(any(), any(), any()))

View File

@@ -8,7 +8,6 @@ package org.whispersystems.textsecuregcm.grpc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@@ -19,19 +18,16 @@ import io.grpc.StatusRuntimeException;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -62,10 +58,11 @@ import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
import org.signal.libsignal.zkgroup.backups.BackupLevel;
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
import org.whispersystems.textsecuregcm.backup.BackupAuthTestUtil;
import org.whispersystems.textsecuregcm.backup.BackupException;
import org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException;
import org.whispersystems.textsecuregcm.backup.BackupManager;
import org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;
import org.whispersystems.textsecuregcm.backup.CopyResult;
import org.whispersystems.textsecuregcm.controllers.ArchiveController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
@@ -90,9 +87,12 @@ class BackupsAnonymousGrpcServiceTest extends
@BeforeEach
void setup() {
when(backupManager.authenticateBackupUserAsync(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(
backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID)));
try {
when(backupManager.authenticateBackupUser(any(), any(), any()))
.thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));
} catch (BackupFailedZkAuthenticationException e) {
Assertions.fail(e);
}
}
@Test
@@ -129,7 +129,7 @@ class BackupsAnonymousGrpcServiceTest extends
@Test
void putMediaBatchSuccess() {
final byte[][] mediaIds = {TestRandomUtil.nextBytes(15), TestRandomUtil.nextBytes(15)};
when(backupManager.copyToBackup(any(), any()))
when(backupManager.copyToBackup(any()))
.thenReturn(Flux.just(
new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[0], 1),
new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[1], 1)));
@@ -174,7 +174,7 @@ class BackupsAnonymousGrpcServiceTest extends
CopyResult.Outcome.SOURCE_WRONG_LENGTH,
CopyResult.Outcome.OUT_OF_QUOTA
};
when(backupManager.copyToBackup(any(), any()))
when(backupManager.copyToBackup(any()))
.thenReturn(Flux.fromStream(IntStream.range(0, 4)
.mapToObj(i -> new CopyResult(
outcomes[i],
@@ -211,17 +211,17 @@ class BackupsAnonymousGrpcServiceTest extends
}
@Test
void getBackupInfo() {
void getBackupInfo() throws BackupException {
when(backupManager.backupInfo(any()))
.thenReturn(new BackupManager.BackupInfo(1, "myBackupDir", "myMediaDir", "filename", Optional.empty()));
final GetBackupInfoResponse response = unauthenticatedServiceStub().getBackupInfo(GetBackupInfoRequest.newBuilder()
.setSignedPresentation(signedPresentation(presentation))
.build());
assertThat(response.getBackupDir()).isEqualTo("myBackupDir");
assertThat(response.getBackupName()).isEqualTo("filename");
assertThat(response.getCdn()).isEqualTo(1);
assertThat(response.getUsedSpace()).isEqualTo(0L);
assertThat(response.getBackupInfo().getBackupDir()).isEqualTo("myBackupDir");
assertThat(response.getBackupInfo().getBackupName()).isEqualTo("filename");
assertThat(response.getBackupInfo().getCdn()).isEqualTo(1);
assertThat(response.getBackupInfo().getUsedSpace()).isEqualTo(0L);
}
@@ -229,7 +229,7 @@ class BackupsAnonymousGrpcServiceTest extends
void list(
@CartesianTest.Values(booleans = {true, false}) final boolean cursorProvided,
@CartesianTest.Values(booleans = {true, false}) final boolean cursorReturned)
throws VerificationFailedException {
throws VerificationFailedException, BackupException {
final byte[] mediaId = TestRandomUtil.nextBytes(15);
final Optional<String> expectedCursor = cursorProvided ? Optional.of("myCursor") : Optional.empty();
@@ -250,15 +250,16 @@ class BackupsAnonymousGrpcServiceTest extends
}
final ListMediaResponse response = unauthenticatedServiceStub().listMedia(request.build());
assertThat(response.getPageCount()).isEqualTo(1);
assertThat(response.getPage(0).getLength()).isEqualTo(100);
assertThat(response.getPage(0).getMediaId().toByteArray()).isEqualTo(mediaId);
assertThat(response.hasCursor() ? response.getCursor() : null).isEqualTo(returnedCursor.orElse(null));
assertThat(response.getListResult().getPageCount()).isEqualTo(1);
assertThat(response.getListResult().getPage(0).getLength()).isEqualTo(100);
assertThat(response.getListResult().getPage(0).getMediaId().toByteArray()).isEqualTo(mediaId);
assertThat(response.getListResult().hasCursor() ? response.getListResult().getCursor() : null)
.isEqualTo(returnedCursor.orElse(null));
}
@Test
void delete() {
void delete() throws BackupException {
final DeleteMediaRequest request = DeleteMediaRequest.newBuilder()
.setSignedPresentation(signedPresentation(presentation))
.addAllItems(IntStream.range(0, 100).mapToObj(i ->
@@ -278,7 +279,7 @@ class BackupsAnonymousGrpcServiceTest extends
}
@Test
void mediaUploadForm() throws RateLimitExceededException {
void mediaUploadForm() throws RateLimitExceededException, BackupException {
when(backupManager.createTemporaryAttachmentUploadDescriptor(any()))
.thenReturn(new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org"));
final GetUploadFormRequest request = GetUploadFormRequest.newBuilder()
@@ -287,10 +288,10 @@ class BackupsAnonymousGrpcServiceTest extends
.build();
final GetUploadFormResponse uploadForm = unauthenticatedServiceStub().getUploadForm(request);
assertThat(uploadForm.getCdn()).isEqualTo(3);
assertThat(uploadForm.getKey()).isEqualTo("abc");
assertThat(uploadForm.getHeadersMap()).containsExactlyEntriesOf(Map.of("k", "v"));
assertThat(uploadForm.getSignedUploadLocation()).isEqualTo("example.org");
assertThat(uploadForm.getUploadForm().getCdn()).isEqualTo(3);
assertThat(uploadForm.getUploadForm().getKey()).isEqualTo("abc");
assertThat(uploadForm.getUploadForm().getHeadersMap()).containsExactlyEntriesOf(Map.of("k", "v"));
assertThat(uploadForm.getUploadForm().getSignedUploadLocation()).isEqualTo("example.org");
// rate limit
Duration duration = Duration.ofSeconds(10);
@@ -309,7 +310,7 @@ class BackupsAnonymousGrpcServiceTest extends
@ParameterizedTest
@MethodSource
public void messagesUploadForm(Optional<Long> uploadLength, boolean expectSuccess) {
public void messagesUploadForm(Optional<Long> uploadLength, boolean allowedSize) throws BackupException {
when(backupManager.createMessageBackupUploadDescriptor(any()))
.thenReturn(new BackupUploadDescriptor(3, "abc", Map.of("k", "v"), "example.org"));
final GetUploadFormRequest.MessagesUploadType.Builder builder = GetUploadFormRequest.MessagesUploadType.newBuilder();
@@ -318,24 +319,20 @@ class BackupsAnonymousGrpcServiceTest extends
.setMessages(builder.build())
.setSignedPresentation(signedPresentation(presentation))
.build();
if (expectSuccess) {
final GetUploadFormResponse uploadForm = unauthenticatedServiceStub().getUploadForm(request);
assertThat(uploadForm.getCdn()).isEqualTo(3);
assertThat(uploadForm.getKey()).isEqualTo("abc");
assertThat(uploadForm.getHeadersMap()).containsExactlyEntriesOf(Map.of("k", "v"));
assertThat(uploadForm.getSignedUploadLocation()).isEqualTo("example.org");
final GetUploadFormResponse response = unauthenticatedServiceStub().getUploadForm(request);
if (allowedSize) {
assertThat(response.getUploadForm().getCdn()).isEqualTo(3);
assertThat(response.getUploadForm().getKey()).isEqualTo("abc");
assertThat(response.getUploadForm().getHeadersMap()).containsExactlyEntriesOf(Map.of("k", "v"));
assertThat(response.getUploadForm().getSignedUploadLocation()).isEqualTo("example.org");
} else {
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> unauthenticatedServiceStub().getUploadForm(request))
.extracting(StatusRuntimeException::getStatus)
.extracting(Status::getCode)
.isEqualTo(Status.FAILED_PRECONDITION.getCode());
assertThat(response.hasExceedsMaxUploadLength()).isTrue();
}
}
@Test
void readAuth() {
void readAuth() throws BackupException {
when(backupManager.generateReadAuth(any(), eq(3))).thenReturn(Map.of("key", "value"));
final GetCdnCredentialsResponse response = unauthenticatedServiceStub().getCdnCredentials(
@@ -343,7 +340,7 @@ class BackupsAnonymousGrpcServiceTest extends
.setCdn(3)
.setSignedPresentation(signedPresentation(presentation))
.build());
assertThat(response.getHeadersMap()).containsExactlyEntriesOf(Map.of("key", "value"));
assertThat(response.getCdnCredentials().getHeadersMap()).containsExactlyEntriesOf(Map.of("key", "value"));
}
private static AuthenticatedBackupUser backupUser(final byte[] backupId, final BackupCredentialType credentialType,

View File

@@ -36,6 +36,7 @@ import org.signal.chat.backup.BackupsGrpc;
import org.signal.chat.backup.GetBackupAuthCredentialsRequest;
import org.signal.chat.backup.GetBackupAuthCredentialsResponse;
import org.signal.chat.backup.RedeemReceiptRequest;
import org.signal.chat.backup.RedeemReceiptResponse;
import org.signal.chat.backup.SetBackupIdRequest;
import org.signal.chat.common.ZkCredential;
import org.signal.libsignal.zkgroup.InvalidInputException;
@@ -54,6 +55,12 @@ import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
import org.whispersystems.textsecuregcm.backup.BackupAuthTestUtil;
import org.whispersystems.textsecuregcm.backup.BackupBadReceiptException;
import org.whispersystems.textsecuregcm.backup.BackupException;
import org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;
import org.whispersystems.textsecuregcm.backup.BackupMissingIdCommitmentException;
import org.whispersystems.textsecuregcm.backup.BackupNotFoundException;
import org.whispersystems.textsecuregcm.backup.BackupPermissionException;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
import org.whispersystems.textsecuregcm.storage.Account;
@@ -61,6 +68,7 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.util.EnumMapUtil;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import javax.annotation.Nullable;
class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, BackupsGrpc.BackupsBlockingStub> {
@@ -98,7 +106,7 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
@Test
void setBackupId() throws RateLimitExceededException {
void setBackupId() throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {
authenticatedServiceStub().setBackupId(
SetBackupIdRequest.newBuilder()
.setMediaBackupAuthCredentialRequest(ByteString.copyFrom(mediaAuthCredRequest.serialize()))
@@ -111,7 +119,8 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
@ParameterizedTest
@ValueSource(booleans = {false, true})
void setBackupIdPartial(boolean media) throws RateLimitExceededException {
void setBackupIdPartial(boolean media)
throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {
final SetBackupIdRequest.Builder builder = SetBackupIdRequest.newBuilder();
if (media) {
builder.setMediaBackupAuthCredentialRequest(ByteString.copyFrom(mediaAuthCredRequest.serialize()));
@@ -141,15 +150,14 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
public static Stream<Arguments> setBackupIdException() {
return Stream.of(
Arguments.of(new RateLimitExceededException(null), Status.RESOURCE_EXHAUSTED),
Arguments.of(Status.INVALID_ARGUMENT.withDescription("test").asRuntimeException(),
Status.INVALID_ARGUMENT)
);
Arguments.of(new BackupPermissionException("test"), Status.INVALID_ARGUMENT),
Arguments.of(new BackupInvalidArgumentException("test"), Status.INVALID_ARGUMENT));
}
@ParameterizedTest
@MethodSource
void setBackupIdException(final Exception ex, final Status expected)
throws RateLimitExceededException {
throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {
doThrow(ex).when(backupAuthManager).commitBackupId(any(), any(), any(), any());
GrpcTestUtils.assertStatusException(
@@ -160,8 +168,18 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
);
}
@Test
void redeemReceipt() throws InvalidInputException, VerificationFailedException {
public static Stream<Arguments> redeemReceipt() {
return Stream.of(
Arguments.of(null, RedeemReceiptResponse.OutcomeCase.SUCCESS),
Arguments.of(new BackupBadReceiptException("test"), RedeemReceiptResponse.OutcomeCase.INVALID_RECEIPT),
Arguments.of(new BackupMissingIdCommitmentException(), RedeemReceiptResponse.OutcomeCase.ACCOUNT_MISSING_COMMITMENT));
}
@ParameterizedTest
@MethodSource
void redeemReceipt(@Nullable final BackupException exception, final RedeemReceiptResponse.OutcomeCase expectedOutcome)
throws InvalidInputException, VerificationFailedException, BackupInvalidArgumentException, BackupMissingIdCommitmentException, BackupBadReceiptException {
final ServerSecretParams params = ServerSecretParams.generate();
final ServerZkReceiptOperations serverOps = new ServerZkReceiptOperations(params);
final ClientZkReceiptOperations clientOps = new ClientZkReceiptOperations(params.getPublicParams());
@@ -171,16 +189,22 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
final ReceiptCredential receiptCredential = clientOps.receiveReceiptCredential(rcrc, rcr);
final ReceiptCredentialPresentation presentation = clientOps.createReceiptCredentialPresentation(receiptCredential);
authenticatedServiceStub().redeemReceipt(RedeemReceiptRequest.newBuilder()
.setPresentation(ByteString.copyFrom(presentation.serialize()))
.build());
if (exception != null) {
doThrow(exception).when(backupAuthManager).redeemReceipt(any(), any());
}
final RedeemReceiptResponse redeemReceiptResponse = authenticatedServiceStub().redeemReceipt(
RedeemReceiptRequest.newBuilder()
.setPresentation(ByteString.copyFrom(presentation.serialize()))
.build());
assertThat(redeemReceiptResponse.getOutcomeCase()).isEqualTo(expectedOutcome);
verify(backupAuthManager).redeemReceipt(account, presentation);
}
@Test
void getCredentials() {
void getCredentials() throws BackupNotFoundException {
final Instant start = Instant.now().truncatedTo(ChronoUnit.DAYS);
final Instant end = start.plus(Duration.ofDays(1));
final RedemptionRange expectedRange = RedemptionRange.inclusive(Clock.systemUTC(), start, end);
@@ -190,9 +214,12 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
BackupLevel.PAID, backupAuthTestUtil.getRequest(messagesBackupKey, AUTHENTICATED_ACI), credentialType,
start, end));
expectedCredentialsByType.forEach((credentialType, expectedCredentials) ->
when(backupAuthManager.getBackupAuthCredentials(any(), eq(credentialType), eq(expectedRange)))
.thenReturn(expectedCredentials));
for (Map.Entry<BackupCredentialType, List<BackupAuthManager.Credential>> entry : expectedCredentialsByType.entrySet()) {
final BackupCredentialType credentialType = entry.getKey();
final List<BackupAuthManager.Credential> expectedCredentials = entry.getValue();
when(backupAuthManager.getBackupAuthCredentials(any(), eq(credentialType), eq(expectedRange)))
.thenReturn(expectedCredentials);
}
final GetBackupAuthCredentialsResponse credentialResponse = authenticatedServiceStub().getBackupAuthCredentials(
GetBackupAuthCredentialsRequest.newBuilder()
@@ -202,8 +229,8 @@ class BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, Back
expectedCredentialsByType.forEach((credentialType, expectedCredentials) -> {
final Map<Long, ZkCredential> creds = switch (credentialType) {
case MESSAGES -> credentialResponse.getMessageCredentialsMap();
case MEDIA -> credentialResponse.getMediaCredentialsMap();
case MESSAGES -> credentialResponse.getCredentials().getMessageCredentialsMap();
case MEDIA -> credentialResponse.getCredentials().getMediaCredentialsMap();
};
assertThat(creds).hasSize(expectedCredentials.size()).containsKey(start.getEpochSecond());