Add support for distinct media backup credentials

Co-authored-by: Ravi Khadiwala <ravi@signal.org>
This commit is contained in:
Jon Chambers
2024-10-29 16:03:10 -04:00
committed by GitHub
parent d335b7a033
commit b21b50873f
16 changed files with 566 additions and 258 deletions

View File

@@ -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) {
}

View File

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

View File

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

View File

@@ -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),

View File

@@ -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(),

View File

@@ -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() {

View File

@@ -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.

View File

@@ -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"));