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