mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-19 23:38:07 +01:00
Add support for distinct media backup credentials
Co-authored-by: Ravi Khadiwala <ravi@signal.org>
This commit is contained in:
@@ -5,6 +5,12 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
|
||||
public record AuthenticatedBackupUser(byte[] backupId, BackupLevel backupLevel, String backupDir, String mediaDir) {}
|
||||
public record AuthenticatedBackupUser(byte[] backupId,
|
||||
BackupCredentialType credentialType,
|
||||
BackupLevel backupLevel,
|
||||
String backupDir,
|
||||
String mediaDir) {
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
@@ -29,7 +30,6 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
@@ -81,22 +81,34 @@ public class BackupAuthManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a credential request containing a blinded backup-id for future use.
|
||||
* Store credential requests containing blinded backup-ids for future use.
|
||||
*
|
||||
* @param account The account using the backup-id
|
||||
* @param backupAuthCredentialRequest A request containing the blinded backup-id
|
||||
* @param account The account using the backup-id
|
||||
* @param messagesBackupCredentialRequest A request containing the blinded backup-id the client will use to upload
|
||||
* message backups
|
||||
* @param mediaBackupCredentialRequest A request containing the blinded backup-id the client will use to upload
|
||||
* media backups
|
||||
* @return A future that completes when the credentialRequest has been stored
|
||||
* @throws RateLimitExceededException If too many backup-ids have been committed
|
||||
*/
|
||||
public CompletableFuture<Void> commitBackupId(final Account account,
|
||||
final BackupAuthCredentialRequest backupAuthCredentialRequest) {
|
||||
final BackupAuthCredentialRequest messagesBackupCredentialRequest,
|
||||
final BackupAuthCredentialRequest mediaBackupCredentialRequest) {
|
||||
if (configuredBackupLevel(account).isEmpty()) {
|
||||
throw Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException();
|
||||
}
|
||||
final byte[] serializedMessageCredentialRequest = messagesBackupCredentialRequest.serialize();
|
||||
final byte[] serializedMediaCredentialRequest = mediaBackupCredentialRequest.serialize();
|
||||
|
||||
byte[] serializedRequest = backupAuthCredentialRequest.serialize();
|
||||
byte[] existingRequest = account.getBackupCredentialRequest();
|
||||
if (existingRequest != null && MessageDigest.isEqual(serializedRequest, existingRequest)) {
|
||||
final boolean messageCredentialRequestMatches = account.getBackupCredentialRequest(BackupCredentialType.MESSAGES)
|
||||
.map(storedCredentialRequest -> MessageDigest.isEqual(storedCredentialRequest, serializedMessageCredentialRequest))
|
||||
.orElse(false);
|
||||
|
||||
final boolean mediaCredentialRequestMatches = account.getBackupCredentialRequest(BackupCredentialType.MEDIA)
|
||||
.map(storedCredentialRequest -> MessageDigest.isEqual(storedCredentialRequest, serializedMediaCredentialRequest))
|
||||
.orElse(false);
|
||||
|
||||
if (messageCredentialRequestMatches && mediaCredentialRequestMatches) {
|
||||
// No need to update or enforce rate limits, this is the credential that the user has already
|
||||
// committed to.
|
||||
return CompletableFuture.completedFuture(null);
|
||||
@@ -105,7 +117,7 @@ public class BackupAuthManager {
|
||||
return rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID)
|
||||
.validateAsync(account.getUuid())
|
||||
.thenCompose(ignored -> this.accountsManager
|
||||
.updateAsync(account, acc -> acc.setBackupCredentialRequest(serializedRequest))
|
||||
.updateAsync(account, a -> a.setBackupCredentialRequests(serializedMessageCredentialRequest, serializedMediaCredentialRequest))
|
||||
.thenRun(Util.NOOP))
|
||||
.toCompletableFuture();
|
||||
}
|
||||
@@ -123,12 +135,14 @@ public class BackupAuthManager {
|
||||
* method will also remove the expired voucher from the account.
|
||||
*
|
||||
* @param account The account to create the credentials for
|
||||
* @param credentialType The type of backup credentials to create
|
||||
* @param redemptionStart The day (must be truncated to a day boundary) the first credential should be valid
|
||||
* @param redemptionEnd The day (must be truncated to a day boundary) the last credential should be valid
|
||||
* @return Credentials and the day on which they may be redeemed
|
||||
*/
|
||||
public CompletableFuture<List<Credential>> getBackupAuthCredentials(
|
||||
final Account account,
|
||||
final BackupCredentialType credentialType,
|
||||
final Instant redemptionStart,
|
||||
final Instant redemptionEnd) {
|
||||
|
||||
@@ -139,7 +153,7 @@ public class BackupAuthManager {
|
||||
if (hasExpiredVoucher(a)) {
|
||||
a.setBackupVoucher(null);
|
||||
}
|
||||
}).thenCompose(updated -> getBackupAuthCredentials(updated, redemptionStart, redemptionEnd));
|
||||
}).thenCompose(updated -> getBackupAuthCredentials(updated, credentialType, redemptionStart, redemptionEnd));
|
||||
}
|
||||
|
||||
// If this account isn't allowed some level of backup access via configuration, don't continue
|
||||
@@ -157,23 +171,20 @@ public class BackupAuthManager {
|
||||
}
|
||||
|
||||
// fetch the blinded backup-id the account should have previously committed to
|
||||
final byte[] committedBytes = account.getBackupCredentialRequest();
|
||||
if (committedBytes == null) {
|
||||
throw Status.NOT_FOUND.withDescription("No blinded backup-id has been added to the account").asRuntimeException();
|
||||
}
|
||||
final byte[] committedBytes = account.getBackupCredentialRequest(credentialType)
|
||||
.orElseThrow(() -> Status.NOT_FOUND.withDescription("No blinded backup-id has been added to the account").asRuntimeException());
|
||||
|
||||
try {
|
||||
// create a credential for every day in the requested period
|
||||
final BackupAuthCredentialRequest credentialReq = new BackupAuthCredentialRequest(committedBytes);
|
||||
return CompletableFuture.completedFuture(Stream
|
||||
.iterate(redemptionStart, curr -> curr.plus(Duration.ofDays(1)))
|
||||
.takeWhile(redemptionTime -> !redemptionTime.isAfter(redemptionEnd))
|
||||
.iterate(redemptionStart, redemptionTime -> !redemptionTime.isAfter(redemptionEnd), curr -> curr.plus(Duration.ofDays(1)))
|
||||
.map(redemptionTime -> {
|
||||
// Check if the account has a voucher that's good for a certain receiptLevel at redemption time, otherwise
|
||||
// use the default receipt level
|
||||
final BackupLevel backupLevel = storedBackupLevel(account, redemptionTime).orElse(configuredBackupLevel);
|
||||
return new Credential(
|
||||
credentialReq.issueCredential(redemptionTime, backupLevel, serverSecretParams),
|
||||
credentialReq.issueCredential(redemptionTime, backupLevel, credentialType, serverSecretParams),
|
||||
redemptionTime);
|
||||
})
|
||||
.toList());
|
||||
@@ -210,7 +221,7 @@ public class BackupAuthManager {
|
||||
|
||||
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
|
||||
|
||||
if (BackupLevelUtil.fromReceiptLevel(receiptLevel) != BackupLevel.MEDIA) {
|
||||
if (BackupLevelUtil.fromReceiptLevel(receiptLevel) != BackupLevel.PAID) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("server does not recognize the requested receipt level")
|
||||
.asRuntimeException();
|
||||
@@ -281,10 +292,10 @@ public class BackupAuthManager {
|
||||
*/
|
||||
private Optional<BackupLevel> configuredBackupLevel(final Account account) {
|
||||
if (inExperiment(BACKUP_MEDIA_EXPERIMENT_NAME, account)) {
|
||||
return Optional.of(BackupLevel.MEDIA);
|
||||
return Optional.of(BackupLevel.PAID);
|
||||
}
|
||||
if (inExperiment(BACKUP_EXPERIMENT_NAME, account)) {
|
||||
return Optional.of(BackupLevel.MESSAGES);
|
||||
return Optional.of(BackupLevel.FREE);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -28,25 +28,22 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;
|
||||
import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.util.AsyncTimerUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
|
||||
public class BackupManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BackupManager.class);
|
||||
|
||||
static final String MESSAGE_BACKUP_NAME = "messageBackup";
|
||||
public static final long MAX_TOTAL_BACKUP_MEDIA_BYTES = DataSize.gibibytes(100).toBytes();
|
||||
static final long MAX_MEDIA_OBJECT_SIZE = DataSize.mebibytes(101).toBytes();
|
||||
@@ -120,8 +117,10 @@ public class BackupManager {
|
||||
// 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
|
||||
// there isn't an existing, different stored public key for the backup-id (verified with a condition expression)
|
||||
final BackupLevel backupLevel = verifyPresentation(presentation).verifySignature(signature, publicKey);
|
||||
return backupsDb.setPublicKey(presentation.getBackupId(), backupLevel, publicKey)
|
||||
final Pair<BackupCredentialType, BackupLevel> credentialTypeAndBackupLevel =
|
||||
verifyPresentation(presentation).verifySignature(signature, publicKey);
|
||||
|
||||
return backupsDb.setPublicKey(presentation.getBackupId(), credentialTypeAndBackupLevel.second(), publicKey)
|
||||
.exceptionally(ExceptionUtils.exceptionallyHandler(PublicKeyConflictException.class, ex -> {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
@@ -144,7 +143,8 @@ public class BackupManager {
|
||||
*/
|
||||
public CompletableFuture<BackupUploadDescriptor> createMessageBackupUploadDescriptor(
|
||||
final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MESSAGES);
|
||||
|
||||
// this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp
|
||||
return backupsDb
|
||||
@@ -154,7 +154,8 @@ public class BackupManager {
|
||||
|
||||
public CompletableFuture<BackupUploadDescriptor> createTemporaryAttachmentUploadDescriptor(
|
||||
final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MEDIA);
|
||||
checkBackupLevel(backupUser, BackupLevel.PAID);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
|
||||
|
||||
return rateLimiters.forDescriptor(RateLimiters.For.BACKUP_ATTACHMENT)
|
||||
.validateAsync(rateLimitKey(backupUser)).thenApply(ignored -> {
|
||||
@@ -172,7 +173,7 @@ public class BackupManager {
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
*/
|
||||
public CompletableFuture<Void> ttlRefresh(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
// update message backup TTL
|
||||
return backupsDb.ttlRefresh(backupUser);
|
||||
}
|
||||
@@ -187,7 +188,7 @@ public class BackupManager {
|
||||
* @return Information about the existing backup
|
||||
*/
|
||||
public CompletableFuture<BackupInfo> backupInfo(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
return backupsDb.describeBackup(backupUser)
|
||||
.thenApply(backupDescription -> new BackupInfo(
|
||||
backupDescription.cdn(),
|
||||
@@ -210,7 +211,8 @@ public class BackupManager {
|
||||
* detailing why the object could not be copied.
|
||||
*/
|
||||
public Flux<CopyResult> copyToBackup(final AuthenticatedBackupUser backupUser, List<CopyParameters> toCopy) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MEDIA);
|
||||
checkBackupLevel(backupUser, BackupLevel.PAID);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
|
||||
|
||||
return Mono
|
||||
// Figure out how many objects we're allowed to copy, updating the quota usage for the amount we are allowed
|
||||
@@ -349,7 +351,7 @@ public class BackupManager {
|
||||
* @return A map of headers to include with CDN requests
|
||||
*/
|
||||
public Map<String, String> generateReadAuth(final AuthenticatedBackupUser backupUser, final int cdnNumber) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
if (cdnNumber != 3) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("unknown cdn").asRuntimeException();
|
||||
}
|
||||
@@ -377,7 +379,7 @@ public class BackupManager {
|
||||
final AuthenticatedBackupUser backupUser,
|
||||
final Optional<String> cursor,
|
||||
final int limit) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
return remoteStorageManager.list(cdnMediaDirectory(backupUser), cursor, limit)
|
||||
.thenApply(result ->
|
||||
new ListMediaResult(
|
||||
@@ -395,7 +397,7 @@ public class BackupManager {
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> deleteEntireBackup(final AuthenticatedBackupUser backupUser) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
checkBackupLevel(backupUser, BackupLevel.FREE);
|
||||
return backupsDb
|
||||
// Try to swap out the backupDir for the user
|
||||
.scheduleBackupDeletion(backupUser)
|
||||
@@ -408,7 +410,8 @@ public class BackupManager {
|
||||
|
||||
public Flux<StorageDescriptor> deleteMedia(final AuthenticatedBackupUser backupUser,
|
||||
final List<StorageDescriptor> storageDescriptors) {
|
||||
checkBackupLevel(backupUser, BackupLevel.MESSAGES);
|
||||
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())) {
|
||||
@@ -492,10 +495,16 @@ public class BackupManager {
|
||||
// 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(),
|
||||
signatureVerifier.verifySignature(signature, authenticationData.publicKey()),
|
||||
authenticationData.backupDir(), authenticationData.mediaDir());
|
||||
credentialTypeAndBackupLevel.first(),
|
||||
credentialTypeAndBackupLevel.second(),
|
||||
authenticationData.backupDir(),
|
||||
authenticationData.mediaDir());
|
||||
})
|
||||
.thenApply(result -> {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME, SUCCESS_TAG_NAME, String.valueOf(true)).increment();
|
||||
@@ -579,7 +588,7 @@ public class BackupManager {
|
||||
|
||||
interface PresentationSignatureVerifier {
|
||||
|
||||
BackupLevel verifySignature(byte[] signature, ECPublicKey publicKey);
|
||||
Pair<BackupCredentialType, BackupLevel> verifySignature(byte[] signature, ECPublicKey publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -611,7 +620,7 @@ public class BackupManager {
|
||||
.withDescription("backup auth credential presentation signature verification failed")
|
||||
.asRuntimeException();
|
||||
}
|
||||
return presentation.getBackupLevel();
|
||||
return new Pair<>(presentation.getType(), presentation.getBackupLevel());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -622,9 +631,34 @@ public class BackupManager {
|
||||
* @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}
|
||||
*/
|
||||
private static void checkBackupLevel(final AuthenticatedBackupUser backupUser, final BackupLevel backupLevel) {
|
||||
@VisibleForTesting
|
||||
static void checkBackupLevel(final AuthenticatedBackupUser backupUser, final BackupLevel backupLevel) {
|
||||
if (backupUser.backupLevel().compareTo(backupLevel) < 0) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME,
|
||||
FAILURE_REASON_TAG_NAME, "level")
|
||||
.increment();
|
||||
|
||||
throw Status.PERMISSION_DENIED
|
||||
.withDescription("credential does not support the requested operation")
|
||||
.asRuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the authenticated backup user is authenticated with the given credential type
|
||||
*
|
||||
* @param backupUser The backup user to check
|
||||
* @param credentialType The credential type to require
|
||||
* @throws {@link Status#PERMISSION_DENIED} error if the backup user is not authenticated with the given
|
||||
* {@code credentialType}
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static void checkBackupCredentialType(final AuthenticatedBackupUser backupUser, final BackupCredentialType credentialType) {
|
||||
if (backupUser.credentialType() != credentialType) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME,
|
||||
FAILURE_REASON_TAG_NAME, "credential_type")
|
||||
.increment();
|
||||
|
||||
throw Status.PERMISSION_DENIED
|
||||
.withDescription("credential does not support the requested operation")
|
||||
.asRuntimeException();
|
||||
|
||||
@@ -87,7 +87,7 @@ public class BackupsDb {
|
||||
// garbage collection of archive objects.
|
||||
public static final String ATTR_LAST_REFRESH = "R";
|
||||
// N: Time in seconds since epoch of the last backup media refresh. This timestamp can only be updated if the client
|
||||
// has BackupLevel.MEDIA, and must be periodically updated to avoid garbage collection of media objects.
|
||||
// has BackupLevel.PAID, and must be periodically updated to avoid garbage collection of media objects.
|
||||
public static final String ATTR_LAST_MEDIA_REFRESH = "MR";
|
||||
// B: A 32 byte public key that should be used to sign the presentation used to authenticate requests against the
|
||||
// backup-id
|
||||
@@ -265,7 +265,7 @@ public class BackupsDb {
|
||||
* Indicates that we couldn't schedule a deletion because one was already scheduled. The caller may want to delete the
|
||||
* objects directly.
|
||||
*/
|
||||
class PendingDeletionException extends IOException {}
|
||||
static class PendingDeletionException extends IOException {}
|
||||
|
||||
/**
|
||||
* Attempt to mark a backup as expired and swap in a new empty backupDir for the user.
|
||||
@@ -285,7 +285,7 @@ public class BackupsDb {
|
||||
final byte[] hashedBackupId = hashedBackupId(backupUser);
|
||||
|
||||
// Clear usage metadata, swap names of things we intend to delete, and record our intent to delete in attr_expired_prefix
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.MEDIA, hashedBackupId)
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)
|
||||
.clearMediaUsage(clock)
|
||||
.expireDirectoryNames(secureRandom, ExpiredBackup.ExpirationType.ALL)
|
||||
.setRefreshTimes(Instant.ofEpochSecond(0))
|
||||
@@ -300,7 +300,7 @@ public class BackupsDb {
|
||||
// is toggling backups on and off. In this case, it should be pretty cheap to directly delete the backup.
|
||||
// Instead of changing the backupDir, just make sure the row has expired/ timestamps and tell the caller we
|
||||
// couldn't schedule the deletion.
|
||||
dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.MEDIA, hashedBackupId)
|
||||
dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)
|
||||
.setRefreshTimes(Instant.ofEpochSecond(0))
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
@@ -399,7 +399,7 @@ public class BackupsDb {
|
||||
}
|
||||
|
||||
// Clear usage metadata, swap names of things we intend to delete, and record our intent to delete in attr_expired_prefix
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.MEDIA, expiredBackup.hashedBackupId())
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, expiredBackup.hashedBackupId())
|
||||
.clearMediaUsage(clock)
|
||||
.expireDirectoryNames(secureRandom, expiredBackup.expirationType())
|
||||
.addRemoveExpression(Map.entry("#mediaRefresh", ATTR_LAST_MEDIA_REFRESH))
|
||||
@@ -433,7 +433,7 @@ public class BackupsDb {
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
} else {
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.MEDIA, hashedBackupId)
|
||||
return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)
|
||||
.addRemoveExpression(Map.entry("#expiredPrefixes", ATTR_EXPIRED_PREFIX))
|
||||
.updateItemBuilder()
|
||||
.build())
|
||||
@@ -722,7 +722,7 @@ public class BackupsDb {
|
||||
Map.entry("#lastRefreshTime", ATTR_LAST_REFRESH),
|
||||
Map.entry(":lastRefreshTime", AttributeValues.n(refreshTime.getEpochSecond())));
|
||||
|
||||
if (backupLevel.compareTo(BackupLevel.MEDIA) >= 0) {
|
||||
if (backupLevel.compareTo(BackupLevel.PAID) >= 0) {
|
||||
// update the media time if we have the appropriate level
|
||||
addSetExpression("#lastMediaRefreshTime = :lastMediaRefreshTime",
|
||||
Map.entry("#lastMediaRefreshTime", ATTR_LAST_MEDIA_REFRESH),
|
||||
|
||||
@@ -23,11 +23,14 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Stream;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.Max;
|
||||
@@ -52,6 +55,7 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
|
||||
@@ -89,11 +93,21 @@ public class ArchiveController {
|
||||
|
||||
public record SetBackupIdRequest(
|
||||
@Schema(description = """
|
||||
A BackupAuthCredentialRequest containing a blinded encrypted backup-id, encoded in standard padded base64
|
||||
A BackupAuthCredentialRequest containing a blinded encrypted backup-id, encoded in standard padded base64.
|
||||
This backup-id should be used for message backups only, and must have the message backup type set on the
|
||||
credential.
|
||||
""", implementation = String.class)
|
||||
@JsonDeserialize(using = BackupAuthCredentialAdapter.CredentialRequestDeserializer.class)
|
||||
@JsonSerialize(using = BackupAuthCredentialAdapter.CredentialRequestSerializer.class)
|
||||
@NotNull BackupAuthCredentialRequest backupAuthCredentialRequest) {}
|
||||
@NotNull BackupAuthCredentialRequest messagesBackupAuthCredentialRequest,
|
||||
|
||||
@Schema(description = """
|
||||
A BackupAuthCredentialRequest containing a blinded encrypted backup-id, encoded in standard padded base64.
|
||||
This backup-id should be used for media only, and must have the media type set on the credential.
|
||||
""", implementation = String.class)
|
||||
@JsonDeserialize(using = BackupAuthCredentialAdapter.CredentialRequestDeserializer.class)
|
||||
@JsonSerialize(using = BackupAuthCredentialAdapter.CredentialRequestSerializer.class)
|
||||
@NotNull BackupAuthCredentialRequest mediaBackupAuthCredentialRequest) {}
|
||||
|
||||
|
||||
@PUT
|
||||
@@ -115,8 +129,9 @@ public class ArchiveController {
|
||||
public CompletionStage<Response> setBackupId(
|
||||
@Mutable @Auth final AuthenticatedDevice account,
|
||||
@Valid @NotNull final SetBackupIdRequest setBackupIdRequest) throws RateLimitExceededException {
|
||||
|
||||
return this.backupAuthManager
|
||||
.commitBackupId(account.getAccount(), setBackupIdRequest.backupAuthCredentialRequest)
|
||||
.commitBackupId(account.getAccount(), setBackupIdRequest.messagesBackupAuthCredentialRequest, setBackupIdRequest.mediaBackupAuthCredentialRequest)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
}
|
||||
|
||||
@@ -166,8 +181,8 @@ public class ArchiveController {
|
||||
}
|
||||
|
||||
public record BackupAuthCredentialsResponse(
|
||||
@Schema(description = "A list of BackupAuthCredentials and their validity periods")
|
||||
List<BackupAuthCredential> credentials) {
|
||||
@Schema(description = "A map of credential types to lists of BackupAuthCredentials and their validity periods")
|
||||
Map<BackupCredentialType, List<BackupAuthCredential>> credentials) {
|
||||
|
||||
public record BackupAuthCredential(
|
||||
@Schema(description = "A BackupAuthCredential, encoded in standard padded base64")
|
||||
@@ -202,14 +217,21 @@ public class ArchiveController {
|
||||
@NotNull @QueryParam("redemptionStartSeconds") Long startSeconds,
|
||||
@NotNull @QueryParam("redemptionEndSeconds") Long endSeconds) {
|
||||
|
||||
return this.backupAuthManager.getBackupAuthCredentials(
|
||||
auth.getAccount(),
|
||||
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
|
||||
.thenApply(creds -> new BackupAuthCredentialsResponse(creds.stream()
|
||||
.map(cred -> new BackupAuthCredentialsResponse.BackupAuthCredential(
|
||||
cred.credential().serialize(),
|
||||
cred.redemptionTime().getEpochSecond()))
|
||||
.toList()));
|
||||
final Map<BackupCredentialType, List<BackupAuthCredentialsResponse.BackupAuthCredential>> credentialsByType =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
return CompletableFuture.allOf(Arrays.stream(BackupCredentialType.values())
|
||||
.map(credentialType -> this.backupAuthManager.getBackupAuthCredentials(
|
||||
auth.getAccount(),
|
||||
credentialType,
|
||||
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
|
||||
.thenAccept(credentials -> credentialsByType.put(credentialType, credentials.stream()
|
||||
.map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(
|
||||
credential.credential().serialize(),
|
||||
credential.redemptionTime().getEpochSecond()))
|
||||
.toList())))
|
||||
.toArray(CompletableFuture[]::new))
|
||||
.thenApply(ignored -> new BackupAuthCredentialsResponse(credentialsByType));
|
||||
}
|
||||
|
||||
|
||||
@@ -227,7 +249,8 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "401", description = """
|
||||
The provided backup auth credential 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""")
|
||||
There is no backup associated with the backup-id in the presentation or
|
||||
The credential was of the wrong type (messages/media)""")
|
||||
@ApiResponse(responseCode = "400", description = "Bad arguments. The request may have been made on an authenticated channel")
|
||||
@interface ApiResponseZkAuth {}
|
||||
|
||||
@@ -453,7 +476,7 @@ public class ArchiveController {
|
||||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature)
|
||||
.thenCompose(backupUser -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser))
|
||||
.thenCompose(backupManager::createTemporaryAttachmentUploadDescriptor)
|
||||
.thenApply(result -> new UploadDescriptorResponse(
|
||||
result.cdn(),
|
||||
result.key(),
|
||||
|
||||
@@ -22,6 +22,7 @@ import java.util.function.Predicate;
|
||||
import javax.annotation.Nullable;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
|
||||
@@ -116,7 +117,11 @@ public class Account {
|
||||
|
||||
@JsonProperty("bcr")
|
||||
@Nullable
|
||||
private byte[] backupCredentialRequest;
|
||||
private byte[] messagesBackupCredentialRequest;
|
||||
|
||||
@JsonProperty("mbcr")
|
||||
@Nullable
|
||||
private byte[] mediaBackupCredentialRequest;
|
||||
|
||||
@JsonProperty("bv")
|
||||
@Nullable
|
||||
@@ -284,7 +289,7 @@ public class Account {
|
||||
requireNotStale();
|
||||
|
||||
return Optional.ofNullable(getPrimaryDevice().getCapabilities())
|
||||
.map(Device.DeviceCapabilities::transfer)
|
||||
.map(DeviceCapabilities::transfer)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
@@ -509,12 +514,22 @@ public class Account {
|
||||
this.svr3ShareSet = svr3ShareSet;
|
||||
}
|
||||
|
||||
public byte[] getBackupCredentialRequest() {
|
||||
return backupCredentialRequest;
|
||||
public void setBackupCredentialRequests(final byte[] messagesBackupCredentialRequest,
|
||||
final byte[] mediaBackupCredentialRequest) {
|
||||
|
||||
requireNotStale();
|
||||
|
||||
this.messagesBackupCredentialRequest = messagesBackupCredentialRequest;
|
||||
this.mediaBackupCredentialRequest = mediaBackupCredentialRequest;
|
||||
}
|
||||
|
||||
public void setBackupCredentialRequest(final byte[] backupCredentialRequest) {
|
||||
this.backupCredentialRequest = backupCredentialRequest;
|
||||
public Optional<byte[]> getBackupCredentialRequest(final BackupCredentialType credentialType) {
|
||||
requireNotStale();
|
||||
|
||||
return Optional.ofNullable(switch (credentialType) {
|
||||
case MESSAGES -> messagesBackupCredentialRequest;
|
||||
case MEDIA -> mediaBackupCredentialRequest;
|
||||
});
|
||||
}
|
||||
|
||||
public @Nullable BackupVoucher getBackupVoucher() {
|
||||
|
||||
@@ -36,6 +36,7 @@ import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.util.AsyncTimerUtil;
|
||||
@@ -321,7 +322,9 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
// Carry over the old backup id commitment. If the new account claimer cannot does not have the secret used to
|
||||
// generate their backup-id, this credential is useless, however if they can produce the same credential they
|
||||
// won't be rate-limited for setting their backup-id.
|
||||
accountToCreate.setBackupCredentialRequest(existingAccount.getBackupCredentialRequest());
|
||||
accountToCreate.setBackupCredentialRequests(
|
||||
existingAccount.getBackupCredentialRequest(BackupCredentialType.MESSAGES).orElse(null),
|
||||
existingAccount.getBackupCredentialRequest(BackupCredentialType.MEDIA).orElse(null));
|
||||
|
||||
// Carry over the old SVR3 share-set. This is required for an account to restore information from SVR. The share-
|
||||
// set is not a secret, if the new account claimer does not have the SVR3 pin, it is useless.
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
package org.whispersystems.textsecuregcm.workers;
|
||||
|
||||
import io.dropwizard.core.Application;
|
||||
import io.dropwizard.core.cli.Cli;
|
||||
import io.dropwizard.core.cli.EnvironmentCommand;
|
||||
import io.dropwizard.core.setup.Environment;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
@@ -18,8 +16,6 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupManager;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.time.Clock;
|
||||
@@ -69,13 +65,13 @@ public class BackupMetricsCommand extends AbstractCommandWithDependencies {
|
||||
Runtime.getRuntime().availableProcessors());
|
||||
|
||||
final DistributionSummary numObjectsMediaTier = Metrics.summary(name(getClass(), "numObjects"),
|
||||
"tier", BackupLevel.MEDIA.name());
|
||||
"tier", BackupLevel.PAID.name());
|
||||
final DistributionSummary bytesUsedMediaTier = Metrics.summary(name(getClass(), "bytesUsed"),
|
||||
"tier", BackupLevel.MEDIA.name());
|
||||
"tier", BackupLevel.PAID.name());
|
||||
final DistributionSummary numObjectsMessagesTier = Metrics.summary(name(getClass(), "numObjects"),
|
||||
"tier", BackupLevel.MESSAGES.name());
|
||||
"tier", BackupLevel.FREE.name());
|
||||
final DistributionSummary bytesUsedMessagesTier = Metrics.summary(name(getClass(), "bytesUsed"),
|
||||
"tier", BackupLevel.MESSAGES.name());
|
||||
"tier", BackupLevel.FREE.name());
|
||||
|
||||
final DistributionSummary timeSinceLastRefresh = Metrics.summary(name(getClass(),
|
||||
"timeSinceLastRefresh"));
|
||||
|
||||
Reference in New Issue
Block a user