Add ArchiveController

Adds endpoints for creating and managing backup objects with ZK
anonymous credentials.
This commit is contained in:
Ravi Khadiwala
2023-09-22 16:32:11 -05:00
committed by ravi-signal
parent ba139dddd8
commit 6b38b538f1
25 changed files with 2296 additions and 13 deletions

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
import io.grpc.Status;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.Util;
/**
* Issues ZK backup auth credentials for authenticated accounts
* <p>
* Authenticated callers can create ZK credentials that contain a blinded backup-id, so that they can later use that
* backup id without the verifier learning that the id is associated with this account.
* <p>
* First use {@link #commitBackupId} to provide a blinded backup-id. This is stored in durable storage. Then the caller
* can use {@link #getBackupAuthCredentials} to retrieve credentials that can subsequently be used to make anonymously
* authenticated requests against their backup-id.
*/
public class BackupAuthManager {
private static final Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);
final static String BACKUP_EXPERIMENT_NAME = "backup";
final static String BACKUP_MEDIA_EXPERIMENT_NAME = "backupMedia";
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final GenericServerSecretParams serverSecretParams;
private final Clock clock;
private final RateLimiters rateLimiters;
private final AccountsManager accountsManager;
public BackupAuthManager(
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final RateLimiters rateLimiters,
final AccountsManager accountsManager,
final GenericServerSecretParams serverSecretParams,
final Clock clock) {
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.rateLimiters = rateLimiters;
this.accountsManager = accountsManager;
this.serverSecretParams = serverSecretParams;
this.clock = clock;
}
/**
* Store a credential request containing a blinded backup-id for future use.
*
* @param account The account using the backup-id
* @param backupAuthCredentialRequest A request containing the blinded backup-id
* @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) throws RateLimitExceededException {
if (receiptLevel(account).isEmpty()) {
throw Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException();
}
byte[] serializedRequest = backupAuthCredentialRequest.serialize();
byte[] existingRequest = account.getBackupCredentialRequest();
if (existingRequest != null && MessageDigest.isEqual(serializedRequest, existingRequest)) {
// No need to update or enforce rate limits, this is the credential that the user has already
// committed to.
return CompletableFuture.completedFuture(null);
}
rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID).validate(account.getUuid());
return this.accountsManager
.updateAsync(account, acc -> acc.setBackupCredentialRequest(serializedRequest))
.thenRun(Util.NOOP);
}
public record Credential(BackupAuthCredentialResponse credential, Instant redemptionTime) {}
/**
* Create a credential for every day between redemptionStart and redemptionEnd
* <p>
* This uses a {@link BackupAuthCredentialRequest} previous stored via {@link this#commitBackupId} to generate the
* credentials.
*
* @param account The account to create the credentials for
* @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 Instant redemptionStart,
final Instant redemptionEnd) {
final long receiptLevel = receiptLevel(account).orElseThrow(
() -> Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException());
final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS);
if (redemptionStart.isAfter(redemptionEnd) ||
redemptionStart.isBefore(startOfDay) ||
redemptionEnd.isAfter(startOfDay.plus(MAX_REDEMPTION_DURATION)) ||
!redemptionStart.equals(redemptionStart.truncatedTo(ChronoUnit.DAYS)) ||
!redemptionEnd.equals(redemptionEnd.truncatedTo(ChronoUnit.DAYS))) {
throw Status.INVALID_ARGUMENT.withDescription("invalid redemption window").asRuntimeException();
}
// 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();
}
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))
.map(redemption -> new Credential(
credentialReq.issueCredential(redemption, receiptLevel, serverSecretParams),
redemption))
.toList());
} catch (InvalidInputException e) {
throw Status.INTERNAL
.withDescription("Could not deserialize stored request credential")
.withCause(e)
.asRuntimeException();
}
}
private Optional<Long> receiptLevel(final Account account) {
if (inExperiment(BACKUP_MEDIA_EXPERIMENT_NAME, account)) {
return Optional.of(BackupTier.MEDIA.getReceiptLevel());
}
if (inExperiment(BACKUP_EXPERIMENT_NAME, account)) {
return Optional.of(BackupTier.MESSAGES.getReceiptLevel());
}
return Optional.empty();
}
private boolean inExperiment(final String experimentName, final Account account) {
return dynamicConfigurationManager.getConfiguration()
.getExperimentEnrollmentConfiguration(experimentName)
.map(config -> config.getEnrolledUuids().contains(account.getUuid()))
.orElse(false);
}
}

View File

@@ -0,0 +1,391 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
import io.grpc.Status;
import io.micrometer.core.instrument.Metrics;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import org.signal.libsignal.protocol.InvalidKeyException;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.Util;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
public class BackupManager {
private static final Logger logger = LoggerFactory.getLogger(BackupManager.class);
static final String MESSAGE_BACKUP_NAME = "messageBackup";
private static final int BACKUP_CDN = 3;
private static final String ZK_AUTHN_COUNTER_NAME = MetricsUtil.name(BackupManager.class, "authentication");
private static final String ZK_AUTHZ_FAILURE_COUNTER_NAME = MetricsUtil.name(BackupManager.class, "authorizationFailure");
private static final String SUCCESS_TAG_NAME = "success";
private static final String FAILURE_REASON_TAG_NAME = "reason";
private final GenericServerSecretParams serverSecretParams;
private final TusBackupCredentialGenerator tusBackupCredentialGenerator;
private final DynamoDbAsyncClient dynamoClient;
private final String backupTableName;
private final Clock clock;
// The backups table
// B: 16 bytes that identifies the backup
public static final String KEY_BACKUP_ID_HASH = "U";
// N: Time in seconds since epoch of the last backup refresh. This timestamp must be periodically updated to avoid
// 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 BackupTier.MEDIA, 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
public static final String ATTR_PUBLIC_KEY = "P";
// N: Bytes consumed by this backup
public static final String ATTR_MEDIA_BYTES_USED = "MB";
// N: Number of media objects in the backup
public static final String ATTR_MEDIA_COUNT = "MC";
// N: The cdn number where the message backup is stored
public static final String ATTR_CDN = "CDN";
public BackupManager(
final GenericServerSecretParams serverSecretParams,
final TusBackupCredentialGenerator tusBackupCredentialGenerator,
final DynamoDbAsyncClient dynamoClient,
final String backupTableName,
final Clock clock) {
this.serverSecretParams = serverSecretParams;
this.dynamoClient = dynamoClient;
this.tusBackupCredentialGenerator = tusBackupCredentialGenerator;
this.backupTableName = backupTableName;
this.clock = clock;
}
/**
* Set the public key for the backup-id.
* <p>
* Once set, calls {@link BackupManager#authenticateBackupUser} can succeed if the presentation is signed with the
* private key corresponding to this public key.
*
* @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
*/
public CompletableFuture<Void> setPublicKey(
final BackupAuthCredentialPresentation presentation,
final byte[] signature,
final ECPublicKey publicKey) {
// 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 BackupTier backupTier = verifySignatureAndCheckPresentation(presentation, signature, publicKey);
if (backupTier.compareTo(BackupTier.MESSAGES) < 0) {
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
throw Status.PERMISSION_DENIED
.withDescription("credential does not support setting public key")
.asRuntimeException();
}
final byte[] hashedBackupId = hashedBackupId(presentation.getBackupId());
return dynamoClient.updateItem(UpdateItemRequest.builder()
.tableName(backupTableName)
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
.updateExpression("SET #publicKey = :publicKey")
.expressionAttributeNames(Map.of("#publicKey", ATTR_PUBLIC_KEY))
.expressionAttributeValues(Map.of(":publicKey", AttributeValues.b(publicKey.serialize())))
.conditionExpression("attribute_not_exists(#publicKey) OR #publicKey = :publicKey")
.build())
.exceptionally(throwable -> {
// There was already a row for this backup-id and it contained a different publicKey
if (ExceptionUtils.unwrap(throwable) instanceof ConditionalCheckFailedException) {
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();
}
throw ExceptionUtils.wrap(throwable);
})
.thenRun(Util.NOOP);
}
/**
* Create a form that may be used to upload a backup file for the backupId encoded in the presentation.
* <p>
* If successful, this also updates the TTL of the backup.
*
* @param backupUser an already ZK authenticated backup user
* @return the upload form
*/
public CompletableFuture<MessageBackupUploadDescriptor> createMessageBackupUploadDescriptor(
final AuthenticatedBackupUser backupUser) {
final byte[] hashedBackupId = hashedBackupId(backupUser);
final String encodedBackupId = encodeForCdn(hashedBackupId);
final long refreshTimeSecs = clock.instant().getEpochSecond();
final List<String> updates = new ArrayList<>(List.of("#cdn = :cdn", "#lastRefresh = :expiration"));
final Map<String, String> expressionAttributeNames = new HashMap<>(Map.of(
"#cdn", ATTR_CDN,
"#lastRefresh", ATTR_LAST_REFRESH));
if (backupUser.backupTier().compareTo(BackupTier.MEDIA) >= 0) {
updates.add("#lastMediaRefresh = :expiration");
expressionAttributeNames.put("#lastMediaRefresh", ATTR_LAST_MEDIA_REFRESH);
}
// this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp
return dynamoClient.updateItem(UpdateItemRequest.builder()
.tableName(backupTableName)
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
.updateExpression("SET %s".formatted(String.join(",", updates)))
.expressionAttributeNames(expressionAttributeNames)
.expressionAttributeValues(Map.of(
":cdn", AttributeValues.n(BACKUP_CDN),
":expiration", AttributeValues.n(refreshTimeSecs)))
.build())
.thenApply(result -> tusBackupCredentialGenerator.generateUpload(encodedBackupId, MESSAGE_BACKUP_NAME));
}
/**
* Update the last update timestamps for the backupId in the presentation
*
* @param backupUser an already ZK authenticated backup user
*/
public CompletableFuture<Void> ttlRefresh(final AuthenticatedBackupUser backupUser) {
if (backupUser.backupTier().compareTo(BackupTier.MESSAGES) < 0) {
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
throw Status.PERMISSION_DENIED
.withDescription("credential does not support ttl operation")
.asRuntimeException();
}
final long refreshTimeSecs = clock.instant().getEpochSecond();
// update message backup TTL
final List<String> updates = new ArrayList<>(Collections.singletonList("#lastRefresh = :expiration"));
final Map<String, String> expressionAttributeNames = new HashMap<>(Map.of("#lastRefresh", ATTR_LAST_REFRESH));
if (backupUser.backupTier().compareTo(BackupTier.MEDIA) >= 0) {
// update media TTL
expressionAttributeNames.put("#lastMediaRefresh", ATTR_LAST_MEDIA_REFRESH);
updates.add("#lastMediaRefresh = :expiration");
}
return dynamoClient.updateItem(UpdateItemRequest.builder()
.tableName(backupTableName)
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupUser))))
.updateExpression("SET %s".formatted(String.join(",", updates)))
.expressionAttributeNames(expressionAttributeNames)
.expressionAttributeValues(Map.of(":expiration", AttributeValues.n(refreshTimeSecs)))
.build())
.thenRun(Util.NOOP);
}
public record BackupInfo(int cdn, String backupSubdir, String messageBackupKey, Optional<Long> mediaUsedSpace) {}
/**
* Retrieve information about the existing backup
*
* @param backupUser an already ZK authenticated backup user
* @return Information about the existing backup
*/
public CompletableFuture<BackupInfo> backupInfo(final AuthenticatedBackupUser backupUser) {
if (backupUser.backupTier().compareTo(BackupTier.MESSAGES) < 0) {
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
throw Status.PERMISSION_DENIED.withDescription("credential does not support info operation")
.asRuntimeException();
}
return backupInfoHelper(backupUser);
}
private CompletableFuture<BackupInfo> backupInfoHelper(final AuthenticatedBackupUser backupUser) {
return dynamoClient.getItem(GetItemRequest.builder()
.tableName(backupTableName)
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupUser))))
.projectionExpression("#cdn,#bytesUsed")
.expressionAttributeNames(Map.of("#cdn", ATTR_CDN, "#bytesUsed", ATTR_MEDIA_BYTES_USED))
.build())
.thenApply(response -> {
if (!response.hasItem()) {
throw Status.NOT_FOUND.withDescription("Backup not found").asRuntimeException();
}
final int cdn = AttributeValues.get(response.item(), ATTR_CDN)
.map(AttributeValue::n)
.map(Integer::parseInt)
.orElseThrow(() -> Status.NOT_FOUND.withDescription("Stored backup not found").asRuntimeException());
final Optional<Long> mediaUsed = AttributeValues.get(response.item(), ATTR_MEDIA_BYTES_USED)
.map(AttributeValue::n)
.map(Long::parseLong);
return new BackupInfo(cdn, encodeForCdn(hashedBackupId(backupUser)), MESSAGE_BACKUP_NAME, mediaUsed);
});
}
/**
* Generate credentials that can be used to read from the backup CDN
*
* @param backupUser an already ZK authenticated backup user
* @return A map of headers to include with CDN requests
*/
public Map<String, String> generateReadAuth(final AuthenticatedBackupUser backupUser) {
if (backupUser.backupTier().compareTo(BackupTier.MESSAGES) < 0) {
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
throw Status.PERMISSION_DENIED
.withDescription("credential does not support read auth operation")
.asRuntimeException();
}
final String encodedBackupId = encodeForCdn(hashedBackupId(backupUser));
return tusBackupCredentialGenerator.readHeaders(encodedBackupId);
}
/**
* 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 On authentication success, the authenticated backup-id and backup-tier encoded in the presentation
*/
public CompletableFuture<AuthenticatedBackupUser> authenticateBackupUser(
final BackupAuthCredentialPresentation presentation,
final byte[] signature) {
final byte[] hashedBackupId = hashedBackupId(presentation.getBackupId());
return dynamoClient.getItem(GetItemRequest.builder()
.tableName(backupTableName)
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
.projectionExpression("#publicKey")
.expressionAttributeNames(Map.of("#publicKey", ATTR_PUBLIC_KEY))
.build())
.thenApply(response -> {
if (!response.hasItem()) {
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
SUCCESS_TAG_NAME, String.valueOf(false),
FAILURE_REASON_TAG_NAME, "missing_public_key")
.increment();
throw Status.NOT_FOUND.withDescription("Backup not found").asRuntimeException();
}
final byte[] publicKeyBytes = AttributeValues.get(response.item(), ATTR_PUBLIC_KEY)
.map(AttributeValue::b)
.map(SdkBytes::asByteArray)
.orElseThrow(() -> Status.INTERNAL
.withDescription("Stored backup missing public key")
.asRuntimeException());
try {
final ECPublicKey publicKey = new ECPublicKey(publicKeyBytes);
return new AuthenticatedBackupUser(
presentation.getBackupId(),
verifySignatureAndCheckPresentation(presentation, signature, publicKey));
} catch (InvalidKeyException e) {
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
SUCCESS_TAG_NAME, String.valueOf(false),
FAILURE_REASON_TAG_NAME, "invalid_public_key")
.increment();
logger.error("Invalid publicKey for backupId hash {}",
HexFormat.of().formatHex(hashedBackupId), e);
throw Status.INTERNAL
.withCause(e)
.withDescription("Could not deserialize stored public key")
.asRuntimeException();
}
})
.thenApply(result -> {
Metrics.counter(ZK_AUTHN_COUNTER_NAME, SUCCESS_TAG_NAME, String.valueOf(true)).increment();
return result;
});
}
/**
* Verify the presentation and return the extracted backup tier
*
* @param presentation A ZK credential presentation that encodes the backupId and the receipt level of the requester
* @return The backup tier this presentation supports
*/
private BackupTier verifySignatureAndCheckPresentation(
final BackupAuthCredentialPresentation presentation,
final byte[] signature,
final ECPublicKey publicKey) {
if (!publicKey.verifySignature(presentation.serialize(), signature)) {
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
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();
}
try {
presentation.verify(clock.instant(), serverSecretParams);
} catch (VerificationFailedException e) {
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
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();
}
return BackupTier
.fromReceiptLevel(presentation.getReceiptLevel())
.orElseThrow(() -> {
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
SUCCESS_TAG_NAME, String.valueOf(false),
FAILURE_REASON_TAG_NAME, "invalid_receipt_level")
.increment();
return Status.PERMISSION_DENIED.withDescription("invalid receipt level").asRuntimeException();
});
}
private static byte[] hashedBackupId(final AuthenticatedBackupUser backupId) {
return hashedBackupId(backupId.backupId());
}
private static byte[] hashedBackupId(final byte[] backupId) {
try {
return Arrays.copyOf(MessageDigest.getInstance("SHA-256").digest(backupId), 16);
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
private static String encodeForCdn(final byte[] bytes) {
return Base64.getUrlEncoder().encodeToString(bytes);
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
public enum BackupTier {
NONE(0),
MESSAGES(10),
MEDIA(20);
private static Map<Long, BackupTier> LOOKUP = Arrays.stream(BackupTier.values())
.collect(Collectors.toMap(BackupTier::getReceiptLevel, Function.identity()));
private long receiptLevel;
private BackupTier(long receiptLevel) {
this.receiptLevel = receiptLevel;
}
long getReceiptLevel() {
return receiptLevel;
}
static Optional<BackupTier> fromReceiptLevel(long receiptLevel) {
return Optional.ofNullable(LOOKUP.get(receiptLevel));
}
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
import java.util.Map;
public record MessageBackupUploadDescriptor(
int cdn,
String key,
Map<String, String> headers,
String signedUploadLocation) {}

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.backup;
import org.apache.http.HttpHeaders;
import org.whispersystems.textsecuregcm.attachments.TusConfiguration;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.Base64;
import java.util.Map;
public class TusBackupCredentialGenerator {
private static final int BACKUP_CDN = 3;
private static String READ_PERMISSION = "read";
private static String WRITE_PERMISSION = "write";
private static String CDN_PATH = "backups";
private static String PERMISSION_SEPARATOR = "$";
// Write entities will be of the form 'write$backups/<string>
private static final String WRITE_ENTITY_PREFIX = String.format("%s%s%s/", WRITE_PERMISSION, PERMISSION_SEPARATOR,
CDN_PATH);
// Read entities will be of the form 'read$backups/<string>
private static final String READ_ENTITY_PREFIX = String.format("%s%s%s/", READ_PERMISSION, PERMISSION_SEPARATOR,
CDN_PATH);
private final ExternalServiceCredentialsGenerator credentialsGenerator;
private final String tusUri;
public TusBackupCredentialGenerator(final TusConfiguration cfg) {
this.tusUri = cfg.uploadUri();
this.credentialsGenerator = credentialsGenerator(Clock.systemUTC(), cfg);
}
private static ExternalServiceCredentialsGenerator credentialsGenerator(final Clock clock,
final TusConfiguration cfg) {
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.prependUsername(false)
.withClock(clock)
.build();
}
public MessageBackupUploadDescriptor generateUpload(final String hashedBackupId, final String objectName) {
if (hashedBackupId.isBlank() || objectName.isBlank()) {
throw new IllegalArgumentException("Upload descriptors must have non-empty keys");
}
final String key = "%s/%s".formatted(hashedBackupId, objectName);
final String entity = WRITE_ENTITY_PREFIX + key;
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(entity);
final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
final Map<String, String> headers = Map.of(
HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials),
"Upload-Metadata", String.format("filename %s", b64Key));
return new MessageBackupUploadDescriptor(
BACKUP_CDN,
key,
headers,
tusUri + "/" + CDN_PATH);
}
public Map<String, String> readHeaders(final String hashedBackupId) {
if (hashedBackupId.isBlank()) {
throw new IllegalArgumentException("Backup subdir name must be non-empty");
}
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(
READ_ENTITY_PREFIX + hashedBackupId);
return Map.of(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials));
}
}