+ * 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.
+ *
+ * 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 dynamicConfigurationManager;
+ private final GenericServerSecretParams serverSecretParams;
+ private final Clock clock;
+ private final RateLimiters rateLimiters;
+ private final AccountsManager accountsManager;
+
+ public BackupAuthManager(
+ final DynamicConfigurationManager 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 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
+ *
+ * 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> 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 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);
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupManager.java
new file mode 100644
index 000000000..69f49ecc8
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupManager.java
@@ -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.
+ *
+ * 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 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.
+ *
+ * If successful, this also updates the TTL of the backup.
+ *
+ * @param backupUser an already ZK authenticated backup user
+ * @return the upload form
+ */
+ public CompletableFuture createMessageBackupUploadDescriptor(
+ final AuthenticatedBackupUser backupUser) {
+ final byte[] hashedBackupId = hashedBackupId(backupUser);
+ final String encodedBackupId = encodeForCdn(hashedBackupId);
+
+ final long refreshTimeSecs = clock.instant().getEpochSecond();
+
+ final List updates = new ArrayList<>(List.of("#cdn = :cdn", "#lastRefresh = :expiration"));
+ final Map 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 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 updates = new ArrayList<>(Collections.singletonList("#lastRefresh = :expiration"));
+ final Map 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 mediaUsedSpace) {}
+
+ /**
+ * Retrieve information about the existing backup
+ *
+ * @param backupUser an already ZK authenticated backup user
+ * @return Information about the existing backup
+ */
+ public CompletableFuture 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 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 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 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
+ *
+ * This validates:
+ *
The presentation was for a credential issued by the server
+ * The credential is in its redemption window
+ * The backup-id matches a previously committed blinded backup-id and server issued receipt level
+ * The signature of the credential matches an existing publicKey associated with this backup-id
+ *
+ * @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 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);
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupTier.java b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupTier.java
new file mode 100644
index 000000000..08ba72c44
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupTier.java
@@ -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 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 fromReceiptLevel(long receiptLevel) {
+ return Optional.ofNullable(LOOKUP.get(receiptLevel));
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/backup/MessageBackupUploadDescriptor.java b/service/src/main/java/org/whispersystems/textsecuregcm/backup/MessageBackupUploadDescriptor.java
new file mode 100644
index 000000000..dff64dd20
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/MessageBackupUploadDescriptor.java
@@ -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 headers,
+ String signedUploadLocation) {}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/backup/TusBackupCredentialGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/backup/TusBackupCredentialGenerator.java
new file mode 100644
index 000000000..1d762606a
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/backup/TusBackupCredentialGenerator.java
@@ -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/
+ 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/
+ 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 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 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));
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java
index 133e74eb4..abce0398e 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java
@@ -47,6 +47,8 @@ public class DynamoDbTables {
}
private final AccountsTableConfiguration accounts;
+
+ private final Table backups;
private final Table clientReleases;
private final Table deletedAccounts;
private final Table deletedAccountsLock;
@@ -68,6 +70,7 @@ public class DynamoDbTables {
public DynamoDbTables(
@JsonProperty("accounts") final AccountsTableConfiguration accounts,
+ @JsonProperty("backups") final Table backups,
@JsonProperty("clientReleases") final Table clientReleases,
@JsonProperty("deletedAccounts") final Table deletedAccounts,
@JsonProperty("deletedAccountsLock") final Table deletedAccountsLock,
@@ -88,6 +91,7 @@ public class DynamoDbTables {
@JsonProperty("verificationSessions") final Table verificationSessions) {
this.accounts = accounts;
+ this.backups = backups;
this.clientReleases = clientReleases;
this.deletedAccounts = deletedAccounts;
this.deletedAccountsLock = deletedAccountsLock;
@@ -114,6 +118,12 @@ public class DynamoDbTables {
return accounts;
}
+ @NotNull
+ @Valid
+ public Table getBackups() {
+ return backups;
+ }
+
@NotNull
@Valid
public Table getClientReleases() {
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java
new file mode 100644
index 000000000..da7854a3b
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.controllers;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import io.dropwizard.auth.Auth;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletionStage;
+import javax.annotation.Nullable;
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.HeaderParam;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.MediaType;
+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.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
+import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
+import org.whispersystems.textsecuregcm.backup.BackupManager;
+import org.whispersystems.textsecuregcm.util.BackupAuthCredentialAdapter;
+import org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter;
+
+@Path("/v1/archives")
+@Tag(name = "Archive")
+public class ArchiveController {
+
+ public final static String X_SIGNAL_ZK_AUTH = "X-Signal-ZK-Auth";
+ public final static String X_SIGNAL_ZK_AUTH_SIGNATURE = "X-Signal-ZK-Auth-Signature";
+
+ private final BackupAuthManager backupAuthManager;
+ private final BackupManager backupManager;
+
+ public ArchiveController(
+ final BackupAuthManager backupAuthManager,
+ final BackupManager backupManager) {
+ this.backupAuthManager = backupAuthManager;
+ this.backupManager = backupManager;
+ }
+
+ public record SetBackupIdRequest(
+ @Schema(description = """
+ A BackupAuthCredentialRequest containing a blinded encrypted backup-id, encoded as a base64 string
+ """, implementation = String.class)
+ @JsonDeserialize(using = BackupAuthCredentialAdapter.CredentialRequestDeserializer.class)
+ @JsonSerialize(using = BackupAuthCredentialAdapter.CredentialRequestSerializer.class)
+ @NotNull BackupAuthCredentialRequest backupAuthCredentialRequest) {}
+
+ @PUT
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/backupid")
+ @Operation(
+ summary = "Set backup id",
+ description = """
+ Set a (blinded) backup-id for the account. Each account may have a single active backup-id that can be used
+ to store and retrieve backups. Once the backup-id is set, BackupAuthCredentials can be generated
+ using /v1/archives/auth.
+
+ The blinded backup-id and the key-pair used to blind it should be derived from a recoverable secret.
+ """)
+ @ApiResponse(responseCode = "204", description = "The backup-id was set")
+ @ApiResponse(responseCode = "400", description = "The provided backup auth credential request was invalid")
+ @ApiResponse(responseCode = "429", description = "Rate limited. Too many attempts to change the backup-id have been made")
+ public CompletionStage setBackupId(
+ @Auth final AuthenticatedAccount account,
+ @Valid @NotNull final SetBackupIdRequest setBackupIdRequest) throws RateLimitExceededException {
+ return this.backupAuthManager.commitBackupId(account.getAccount(), setBackupIdRequest.backupAuthCredentialRequest);
+ }
+
+ public record BackupAuthCredentialsResponse(
+ @Schema(description = "A list of BackupAuthCredentials and their validity periods")
+ List credentials) {
+
+ public record BackupAuthCredential(
+ @Schema(description = "A base64 encoded BackupAuthCredential")
+ byte[] credential,
+ @Schema(description = "The day on which this credential is valid. Seconds since epoch truncated to day boundary")
+ long redemptionTime) {}
+ }
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/auth")
+ @Operation(
+ summary = "Fetch ZK credentials ",
+ description = """
+ After setting a blinded backup-id with PUT /v1/archives/, this fetches credentials that can be used to perform
+ operations against that backup-id. Clients may (and should) request up to 7 days of credentials at a time.
+
+ The redemptionStart and redemptionEnd seconds must be UTC day aligned, and must not span more than 7 days.
+ """)
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BackupAuthCredentialsResponse.class)))
+ @ApiResponse(responseCode = "400", description = "The start/end did not meet alignment/duration requirements")
+ @ApiResponse(responseCode = "404", description = "Could not find an existing blinded backup id")
+ @ApiResponse(responseCode = "429", description = "Rate limited.")
+ public CompletionStage getBackupZKCredentials(
+ @Auth AuthenticatedAccount auth,
+ @NotNull @QueryParam("redemptionStartSeconds") Integer startSeconds,
+ @NotNull @QueryParam("redemptionEndSeconds") Integer 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()));
+ }
+
+
+ /**
+ * API annotation for endpoints that take anonymous auth. All anonymous endpoints
+ * 400 if regular auth is used by accident
+ * 401 if the anonymous auth invalid
+ * 403 if the anonymous credential does not have sufficient permissions
+ */
+ @Target(ElementType.METHOD)
+ @Retention(RetentionPolicy.RUNTIME)
+ @ApiResponse(
+ responseCode = "403",
+ description = "Forbidden. The request had insufficient permissions to perform the requested action")
+ @ApiResponse(responseCode = "401", description = "The provided backup auth credential presentation could not be verified")
+ @ApiResponse(responseCode = "400", description = "Bad arguments. The request may have been made on an authenticated channel")
+ @interface ApiResponseZkAuth {}
+
+ public record BackupAuthCredentialPresentationHeader(BackupAuthCredentialPresentation presentation) {
+
+ private static final String DESCRIPTION = "Presentation of a ZK backup auth credential acquired from /v1/archives/auth as a base64 encoded string";
+
+ public BackupAuthCredentialPresentationHeader(final String header) {
+ this(deserialize(header));
+ }
+
+ private static BackupAuthCredentialPresentation deserialize(final String base64Presentation) {
+ byte[] bytes = Base64.getDecoder().decode(base64Presentation);
+ try {
+ return new BackupAuthCredentialPresentation(bytes);
+ } catch (InvalidInputException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+ }
+
+ public record BackupAuthCredentialPresentationSignature(byte[] signature) {
+
+ private static final String DESCRIPTION = "Signature of the ZK auth credential's presentation as a base64 encoded string";
+
+ public BackupAuthCredentialPresentationSignature(final String header) {
+ this(Base64.getDecoder().decode(header));
+ }
+ }
+
+ public record ReadAuthResponse(
+ @Schema(description = "Auth headers to include with cdn read requests") Map headers) {}
+
+ @GET
+ @Path("/auth/read")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(
+ summary = "Get CDN read credentials",
+ description = "Retrieve credentials used to read objects stored on the backup cdn")
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ReadAuthResponse.class)))
+ @ApiResponse(responseCode = "429", description = "Rate limited.")
+ @ApiResponseZkAuth
+ public CompletionStage readAuth(
+ @Auth final Optional account,
+
+ @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,
+
+ @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature) {
+ if (account.isPresent()) {
+ throw new BadRequestException("must not use authenticated connection for anonymous operations");
+ }
+ return backupManager.authenticateBackupUser(presentation.presentation, signature.signature)
+ .thenApply(backupManager::generateReadAuth)
+ .thenApply(ReadAuthResponse::new);
+ }
+
+ public record BackupInfoResponse(
+ @Schema(description = "If present, the CDN type where the message backup is stored")
+ int cdn,
+
+ @Schema(description = "If present, the directory of your backup data on the cdn.")
+ String backupDir,
+
+ @Schema(description = "If present, the name of the most recent message backup on the cdn. The backup is at /backupDir/backupName")
+ String backupName,
+
+ @Nullable
+ @Schema(description = "The amount of space used to store media")
+ Long usedSpace) {}
+
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(
+ summary = "Fetch backup info",
+ description = "Retrieve information about the currently stored backup")
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BackupInfoResponse.class)))
+ @ApiResponse(responseCode = "404", description = "No existing backups found")
+ @ApiResponse(responseCode = "429", description = "Rate limited.")
+ @ApiResponseZkAuth
+ public CompletionStage backupInfo(
+ @Auth final Optional account,
+
+ @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH) final BackupAuthCredentialPresentationHeader presentation,
+
+ @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature) {
+ if (account.isPresent()) {
+ throw new BadRequestException("must not use authenticated connection for anonymous operations");
+ }
+
+ return backupManager.authenticateBackupUser(presentation.presentation, signature.signature)
+ .thenCompose(backupManager::backupInfo)
+ .thenApply(backupInfo -> new BackupInfoResponse(
+ backupInfo.cdn(),
+ backupInfo.backupSubdir(),
+ backupInfo.messageBackupKey(),
+ backupInfo.mediaUsedSpace().orElse(null)));
+ }
+
+ public record SetPublicKeyRequest(
+ @JsonSerialize(using = ECPublicKeyAdapter.Serializer.class)
+ @JsonDeserialize(using = ECPublicKeyAdapter.Deserializer.class)
+ @Schema(type = "string", description = "The public key, serialized in libsignal's elliptic-curve public key format and then base64-encoded.")
+ ECPublicKey backupIdPublicKey) {}
+
+ @PUT
+ @Path("/keys")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Operation(
+ summary = "Set public key",
+ description = """
+ Permanently set the public key of an ED25519 key-pair for the backup-id. All requests that provide a anonymous
+ BackupAuthCredentialPresentation (including this one!) must also sign the presentation with the private key
+ corresponding to the provided public key.
+ """)
+ @ApiResponseZkAuth
+ @ApiResponse(responseCode = "204", description = "The public key was set")
+ @ApiResponse(responseCode = "429", description = "Rate limited.")
+ public CompletionStage setPublicKey(
+ @Auth final Optional account,
+
+ @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,
+
+ @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,
+
+ @NotNull SetPublicKeyRequest setPublicKeyRequest) {
+ return backupManager.setPublicKey(
+ presentation.presentation, signature.signature,
+ setPublicKeyRequest.backupIdPublicKey);
+ }
+
+
+ public record MessageBackupResponse(
+ @Schema(description = "Indicates the CDN type. 3 indicates resumable uploads using TUS")
+ int cdn,
+ @Schema(description = "The location within the specified cdn where the finished upload can be found.")
+ String key,
+ @Schema(description = "A map of headers to include with all upload requests. Potentially contains time-limited upload credentials")
+ Map headers,
+ @Schema(description = "The URL to upload to with the appropriate protocol")
+ String signedUploadLocation) {}
+
+ @GET
+ @Path("/upload/form")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(
+ summary = "Fetch message backup upload form",
+ description = "Retrieve an upload form that can be used to perform a resumable upload of a message backup.")
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = MessageBackupResponse.class)))
+ @ApiResponse(responseCode = "429", description = "Rate limited.")
+ @ApiResponseZkAuth
+ public CompletionStage backup(
+ @Auth final Optional account,
+
+ @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,
+
+ @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature) {
+ if (account.isPresent()) {
+ throw new BadRequestException("must not use authenticated connection for anonymous operations");
+ }
+ return backupManager.authenticateBackupUser(presentation.presentation, signature.signature)
+ .thenCompose(backupManager::createMessageBackupUploadDescriptor)
+ .thenApply(result -> new MessageBackupResponse(
+ result.cdn(),
+ result.key(),
+ result.headers(),
+ result.signedUploadLocation()));
+ }
+
+
+ @POST
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(
+ summary = "Refresh backup",
+ description = """
+ Indicate that this backup is still active. Clients must periodically upload new backups or perform a refresh
+ via a POST request. If a backup is not refreshed, after 30 days it may be deleted.
+ """)
+ @ApiResponse(responseCode = "204", description = "The backup was successfully refreshed")
+ @ApiResponse(responseCode = "429", description = "Rate limited.")
+ @ApiResponseZkAuth
+ public CompletionStage refresh(
+ @Auth final Optional account,
+
+ @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH) final BackupAuthCredentialPresentationHeader presentation,
+
+ @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))
+ @NotNull
+ @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature) {
+ if (account.isPresent()) {
+ throw new BadRequestException("must not use authenticated connection for anonymous operations");
+ }
+ return backupManager
+ .authenticateBackupUser(presentation.presentation, signature.signature)
+ .thenCompose(backupManager::ttlRefresh);
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
index a93b5fa75..6e50a1b4a 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
@@ -46,6 +46,7 @@ public class RateLimiters extends BaseRateLimiters {
RATE_LIMIT_RESET("rateLimitReset", true, new RateLimiterConfig(2, Duration.ofHours(12))),
RECAPTCHA_CHALLENGE_ATTEMPT("recaptchaChallengeAttempt", true, new RateLimiterConfig(10, Duration.ofMinutes(144))),
RECAPTCHA_CHALLENGE_SUCCESS("recaptchaChallengeSuccess", true, new RateLimiterConfig(2, Duration.ofHours(12))),
+ SET_BACKUP_ID("setBackupId", true, new RateLimiterConfig(2, Duration.ofDays(7))),
PUSH_CHALLENGE_ATTEMPT("pushChallengeAttempt", true, new RateLimiterConfig(10, Duration.ofMinutes(144))),
PUSH_CHALLENGE_SUCCESS("pushChallengeSuccess", true, new RateLimiterConfig(2, Duration.ofHours(12))),
CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, Duration.ofMinutes(15))),
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/GrpcStatusRuntimeExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/GrpcStatusRuntimeExceptionMapper.java
new file mode 100644
index 000000000..d76803800
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/GrpcStatusRuntimeExceptionMapper.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.mappers;
+
+import io.dropwizard.jersey.errors.ErrorMessage;
+import io.grpc.StatusRuntimeException;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import javax.ws.rs.ext.Provider;
+
+@Provider
+public class GrpcStatusRuntimeExceptionMapper implements ExceptionMapper {
+
+ @Override
+ public Response toResponse(final StatusRuntimeException exception) {
+ int httpCode = switch (exception.getStatus().getCode()) {
+ case OK -> 200;
+ case INVALID_ARGUMENT, FAILED_PRECONDITION, OUT_OF_RANGE -> 400;
+ case UNAUTHENTICATED -> 401;
+ case PERMISSION_DENIED -> 403;
+ case NOT_FOUND -> 404;
+ case ALREADY_EXISTS, ABORTED -> 409;
+ case CANCELLED -> 499;
+ case UNKNOWN, UNIMPLEMENTED, DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, INTERNAL, UNAVAILABLE, DATA_LOSS -> 500;
+ };
+
+ return Response.status(httpCode)
+ .entity(new ErrorMessage(httpCode, exception.getMessage()))
+ .type(MediaType.APPLICATION_JSON_TYPE)
+ .build();
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java
index ec41da64e..059d85df2 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java
@@ -100,6 +100,9 @@ public class Account {
@JsonProperty("inCds")
private boolean discoverableByPhoneNumber = true;
+ @JsonProperty("bcr")
+ private byte[] backupCredentialRequest;
+
@JsonProperty
private int version;
@@ -486,6 +489,14 @@ public class Account {
this.version = version;
}
+ public byte[] getBackupCredentialRequest() {
+ return backupCredentialRequest;
+ }
+
+ public void setBackupCredentialRequest(final byte[] backupCredentialRequest) {
+ this.backupCredentialRequest = backupCredentialRequest;
+ }
+
/**
* Have all this account's devices been manually locked?
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/BackupAuthCredentialAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/BackupAuthCredentialAdapter.java
new file mode 100644
index 000000000..4e4211cd3
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/BackupAuthCredentialAdapter.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.util;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.Metrics;
+import java.io.IOException;
+import java.util.Base64;
+import org.signal.libsignal.zkgroup.InvalidInputException;
+import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
+import org.signal.libsignal.zkgroup.internal.ByteArray;
+import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
+
+public class BackupAuthCredentialAdapter {
+
+ private static final Counter INVALID_BASE64_COUNTER =
+ Metrics.counter(MetricsUtil.name(BackupAuthCredentialAdapter.class, "invalidBase64"));
+
+ private static final Counter INVALID_BYTES_COUNTER =
+ Metrics.counter(MetricsUtil.name(BackupAuthCredentialAdapter.class, "invalidBackupAuthObject"));
+
+ abstract static class GenericDeserializer extends JsonDeserializer {
+
+ abstract T deserialize(final byte[] bytes) throws InvalidInputException;
+
+ @Override
+ public T deserialize(final JsonParser parser, final DeserializationContext deserializationContext)
+ throws IOException {
+ final byte[] bytes;
+ try {
+ bytes = Base64.getDecoder().decode(parser.getValueAsString());
+ } catch (final IllegalArgumentException e) {
+ INVALID_BASE64_COUNTER.increment();
+ throw new JsonParseException(parser, "Could not parse string as a base64-encoded value", e);
+ }
+ try {
+ return deserialize(bytes);
+ } catch (InvalidInputException e) {
+ INVALID_BYTES_COUNTER.increment();
+ throw new JsonParseException(parser, "Could not interpret bytes as a BackupAuth object");
+ }
+ }
+ }
+
+ static class GenericSerializer extends JsonSerializer {
+
+ @Override
+ public void serialize(final T t, final JsonGenerator jsonGenerator, final SerializerProvider serializerProvider)
+ throws IOException {
+ jsonGenerator.writeString(Base64.getEncoder().encodeToString(t.serialize()));
+ }
+ }
+
+ public static class CredentialRequestSerializer extends GenericSerializer {}
+ public static class CredentialRequestDeserializer extends GenericDeserializer {
+ @Override
+ BackupAuthCredentialRequest deserialize(final byte[] bytes) throws InvalidInputException {
+ return new BackupAuthCredentialRequest(bytes);
+ }
+ }
+
+
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java
new file mode 100644
index 000000000..8995f889f
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.backup;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.RandomUtils;
+import org.assertj.core.api.Assertions;
+import org.assertj.core.api.ThrowableAssert;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mockito;
+import org.signal.libsignal.zkgroup.VerificationFailedException;
+import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
+import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext;
+import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
+import org.whispersystems.textsecuregcm.limits.RateLimiter;
+import org.whispersystems.textsecuregcm.limits.RateLimiters;
+import org.whispersystems.textsecuregcm.storage.Account;
+import org.whispersystems.textsecuregcm.storage.AccountsManager;
+import org.whispersystems.textsecuregcm.tests.util.ExperimentHelper;
+import org.whispersystems.textsecuregcm.util.TestClock;
+
+public class BackupAuthManagerTest {
+ private final UUID aci = UUID.randomUUID();
+ private final byte[] backupKey = RandomUtils.nextBytes(32);
+ private final TestClock clock = TestClock.now();
+ private final BackupAuthTestUtil backupAuthTestUtil = new BackupAuthTestUtil(clock);
+
+ @BeforeEach
+ void setUp() {
+ clock.unpin();
+ }
+
+
+ @ParameterizedTest
+ @EnumSource
+ void commitRequiresBackupTier(final BackupTier backupTier) {
+ final AccountsManager accountsManager = mock(AccountsManager.class);
+ final BackupAuthManager authManager = new BackupAuthManager(
+ ExperimentHelper.withEnrollment(experimentName(backupTier), aci),
+ allowRateLimiter(),
+ accountsManager,
+ backupAuthTestUtil.params,
+ clock);
+ final Account account = mock(Account.class);
+ when(account.getUuid()).thenReturn(aci);
+ when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account));
+
+ final ThrowableAssert.ThrowingCallable commit = () ->
+ authManager.commitBackupId(account, backupAuthTestUtil.getRequest(backupKey, aci)).join();
+ if (backupTier == BackupTier.NONE) {
+ Assertions.assertThatExceptionOfType(StatusRuntimeException.class)
+ .isThrownBy(commit)
+ .extracting(ex -> ex.getStatus().getCode())
+ .isEqualTo(Status.Code.PERMISSION_DENIED);
+ } else {
+ Assertions.assertThatNoException().isThrownBy(commit);
+ }
+ }
+
+
+ @ParameterizedTest
+ @EnumSource
+ void credentialsRequiresBackupTier(final BackupTier backupTier) {
+ final BackupAuthManager authManager = new BackupAuthManager(
+ ExperimentHelper.withEnrollment(experimentName(backupTier), aci),
+ allowRateLimiter(),
+ mock(AccountsManager.class),
+ backupAuthTestUtil.params,
+ clock);
+
+ final Account account = mock(Account.class);
+ when(account.getUuid()).thenReturn(aci);
+ when(account.getBackupCredentialRequest()).thenReturn(backupAuthTestUtil.getRequest(backupKey, aci).serialize());
+
+ final ThrowableAssert.ThrowingCallable getCreds = () ->
+ assertThat(authManager.getBackupAuthCredentials(account,
+ clock.instant().truncatedTo(ChronoUnit.DAYS),
+ clock.instant().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.DAYS)).join())
+ .hasSize(2);
+ if (backupTier == BackupTier.NONE) {
+ Assertions.assertThatExceptionOfType(StatusRuntimeException.class)
+ .isThrownBy(getCreds)
+ .extracting(ex -> ex.getStatus().getCode())
+ .isEqualTo(Status.Code.PERMISSION_DENIED);
+ } else {
+ Assertions.assertThatNoException().isThrownBy(getCreds);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"NONE"})
+ void getReceiptCredentials(final BackupTier backupTier) throws VerificationFailedException {
+ final BackupAuthManager authManager = new BackupAuthManager(
+ ExperimentHelper.withEnrollment(experimentName(backupTier), aci),
+ allowRateLimiter(),
+ mock(AccountsManager.class),
+ backupAuthTestUtil.params,
+ clock);
+
+ final BackupAuthCredentialRequestContext requestContext = BackupAuthCredentialRequestContext.create(backupKey, aci);
+
+ final Account account = mock(Account.class);
+ when(account.getUuid()).thenReturn(aci);
+ when(account.getBackupCredentialRequest()).thenReturn(requestContext.getRequest().serialize());
+
+ final Instant start = clock.instant().truncatedTo(ChronoUnit.DAYS);
+ final List creds = authManager.getBackupAuthCredentials(account,
+ start, start.plus(Duration.ofDays(7))).join();
+
+ assertThat(creds).hasSize(8);
+ Instant redemptionTime = start;
+ for (BackupAuthManager.Credential cred : creds) {
+ requestContext.receiveResponse(cred.credential(), backupAuthTestUtil.params.getPublicParams(),
+ backupTier.getReceiptLevel());
+ assertThat(cred.redemptionTime().getEpochSecond())
+ .isEqualTo(redemptionTime.getEpochSecond());
+ redemptionTime = redemptionTime.plus(Duration.ofDays(1));
+ }
+ }
+
+ static Stream invalidCredentialTimeWindows() {
+ final Duration max = Duration.ofDays(7);
+ final Instant day0 = Instant.EPOCH;
+ final Instant day1 = Instant.EPOCH.plus(Duration.ofDays(1));
+ return Stream.of(
+ // non-truncated start
+ Arguments.of(Instant.ofEpochSecond(100), day0.plus(max), Instant.ofEpochSecond(100)),
+ // non-truncated end
+ Arguments.of(day0, Instant.ofEpochSecond(1).plus(max), Instant.ofEpochSecond(100)),
+ // start to old
+ Arguments.of(day0, day0.plus(max), day1),
+ // end to new
+ Arguments.of(day1, day1.plus(max), day0),
+ // end before start
+ Arguments.of(day1, day0, day1),
+ // window too big
+ Arguments.of(day0, day0.plus(max).plus(Duration.ofDays(1)), Instant.ofEpochSecond(100))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void invalidCredentialTimeWindows(final Instant requestRedemptionStart, final Instant requestRedemptionEnd,
+ final Instant now) {
+ final BackupAuthManager authManager = new BackupAuthManager(
+ ExperimentHelper.withEnrollment(experimentName(BackupTier.MESSAGES), aci),
+ allowRateLimiter(),
+ mock(AccountsManager.class),
+ backupAuthTestUtil.params,
+ clock);
+
+ final Account account = mock(Account.class);
+ when(account.getUuid()).thenReturn(aci);
+ when(account.getBackupCredentialRequest()).thenReturn(backupAuthTestUtil.getRequest(backupKey, aci).serialize());
+
+ clock.pin(now);
+ assertThatExceptionOfType(StatusRuntimeException.class)
+ .isThrownBy(
+ () -> authManager.getBackupAuthCredentials(account, requestRedemptionStart, requestRedemptionEnd).join())
+ .extracting(ex -> ex.getStatus().getCode())
+ .isEqualTo(Status.Code.INVALID_ARGUMENT);
+ }
+
+ @Test
+ void testRateLimits() throws RateLimitExceededException {
+ final AccountsManager accountsManager = mock(AccountsManager.class);
+ final BackupAuthManager authManager = new BackupAuthManager(
+ ExperimentHelper.withEnrollment(experimentName(BackupTier.MESSAGES), aci),
+ denyRateLimiter(aci),
+ accountsManager,
+ backupAuthTestUtil.params,
+ clock);
+
+ final BackupAuthCredentialRequest credentialRequest = backupAuthTestUtil.getRequest(backupKey, aci);
+
+ final Account account = mock(Account.class);
+ when(account.getUuid()).thenReturn(aci);
+ when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(account));
+
+ // Should be rate limited
+ assertThatExceptionOfType(RateLimitExceededException.class)
+ .isThrownBy(() -> authManager.commitBackupId(account, credentialRequest).join());
+
+ // If we don't change the request, shouldn't be rate limited
+ when(account.getBackupCredentialRequest()).thenReturn(credentialRequest.serialize());
+ assertDoesNotThrow(() -> authManager.commitBackupId(account, credentialRequest).join());
+ }
+
+ private static String experimentName(BackupTier backupTier) {
+ return switch (backupTier) {
+ case MESSAGES -> BackupAuthManager.BACKUP_EXPERIMENT_NAME;
+ case MEDIA -> BackupAuthManager.BACKUP_MEDIA_EXPERIMENT_NAME;
+ case NONE -> "fake_experiment";
+ };
+ }
+
+ private static RateLimiters allowRateLimiter() {
+ final RateLimiters limiters = mock(RateLimiters.class);
+ final RateLimiter limiter = mock(RateLimiter.class);
+ when(limiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID)).thenReturn(limiter);
+ return limiters;
+ }
+
+ private static RateLimiters denyRateLimiter(final UUID aci) throws RateLimitExceededException {
+ final RateLimiters limiters = mock(RateLimiters.class);
+ final RateLimiter limiter = mock(RateLimiter.class);
+ doThrow(new RateLimitExceededException(null, false)).when(limiter).validate(aci);
+ when(limiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID)).thenReturn(limiter);
+ return limiters;
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthTestUtil.java b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthTestUtil.java
new file mode 100644
index 000000000..9efade859
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthTestUtil.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.backup;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.UUID;
+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.BackupAuthCredentialRequest;
+import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext;
+import org.whispersystems.textsecuregcm.storage.Account;
+import org.whispersystems.textsecuregcm.tests.util.ExperimentHelper;
+
+public class BackupAuthTestUtil {
+
+ final GenericServerSecretParams params = GenericServerSecretParams.generate();
+ final Clock clock;
+
+ public BackupAuthTestUtil(final Clock clock) {
+ this.clock = clock;
+ }
+
+ public BackupAuthCredentialRequest getRequest(final byte[] backupKey, final UUID aci) {
+ return BackupAuthCredentialRequestContext.create(backupKey, aci).getRequest();
+ }
+
+ public BackupAuthCredentialPresentation getPresentation(
+ final BackupTier backupTier, final byte[] backupKey, final UUID aci)
+ throws VerificationFailedException {
+ final BackupAuthCredentialRequestContext ctx = BackupAuthCredentialRequestContext.create(backupKey, aci);
+ return ctx.receiveResponse(
+ ctx.getRequest().issueCredential(clock.instant().truncatedTo(ChronoUnit.DAYS), backupTier.getReceiptLevel(), params),
+ params.getPublicParams(),
+ backupTier.getReceiptLevel())
+ .present(params.getPublicParams());
+ }
+
+ public List getCredentials(
+ final BackupTier backupTier,
+ final BackupAuthCredentialRequest request,
+ final Instant redemptionStart,
+ final Instant redemptionEnd) {
+ final UUID aci = UUID.randomUUID();
+
+ final String experimentName = switch (backupTier) {
+ case NONE -> "notUsed";
+ case MESSAGES -> BackupAuthManager.BACKUP_EXPERIMENT_NAME;
+ case MEDIA -> BackupAuthManager.BACKUP_MEDIA_EXPERIMENT_NAME;
+ };
+ final BackupAuthManager issuer = new BackupAuthManager(
+ ExperimentHelper.withEnrollment(experimentName, aci), null, null, params, clock);
+ Account account = mock(Account.class);
+ when(account.getUuid()).thenReturn(aci);
+ when(account.getBackupCredentialRequest()).thenReturn(request.serialize());
+ return issuer.getBackupAuthCredentials(account, redemptionStart, redemptionEnd).join();
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupManagerTest.java
new file mode 100644
index 000000000..619486337
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupManagerTest.java
@@ -0,0 +1,284 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.backup;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.RandomUtils;
+import org.assertj.core.api.ThrowableAssert;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.signal.libsignal.protocol.ecc.Curve;
+import org.signal.libsignal.protocol.ecc.ECKeyPair;
+import org.signal.libsignal.zkgroup.InvalidInputException;
+import org.signal.libsignal.zkgroup.VerificationFailedException;
+import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;
+import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
+import org.whispersystems.textsecuregcm.backup.BackupManager.BackupInfo;
+import org.whispersystems.textsecuregcm.storage.DynamoDbExtension;
+import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;
+import org.whispersystems.textsecuregcm.util.AttributeValues;
+import org.whispersystems.textsecuregcm.util.ExceptionUtils;
+import org.whispersystems.textsecuregcm.util.TestClock;
+import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
+
+public class BackupManagerTest {
+
+ @RegisterExtension
+ private static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(
+ DynamoDbExtensionSchema.Tables.BACKUPS);
+
+ private final TestClock testClock = TestClock.now();
+ private final BackupAuthTestUtil backupAuthTestUtil = new BackupAuthTestUtil(testClock);
+ private final TusBackupCredentialGenerator tusCredentialGenerator = mock(TusBackupCredentialGenerator.class);
+ private final byte[] backupKey = RandomUtils.nextBytes(32);
+ private final UUID aci = UUID.randomUUID();
+
+ private BackupManager backupManager;
+
+ @BeforeEach
+ public void setup() {
+ reset(tusCredentialGenerator);
+ testClock.unpin();
+ this.backupManager = new BackupManager(
+ backupAuthTestUtil.params,
+ tusCredentialGenerator,
+ DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
+ DynamoDbExtensionSchema.Tables.BACKUPS.tableName(),
+ testClock);
+ }
+
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"NONE"})
+ public void createBackup(final BackupTier backupTier) throws InvalidInputException, VerificationFailedException {
+
+ final Instant now = Instant.ofEpochSecond(Duration.ofDays(1).getSeconds());
+ testClock.pin(now);
+
+ final AuthenticatedBackupUser backupUser = backupUser(RandomUtils.nextBytes(16), backupTier);
+ final String encodedBackupId = Base64.getUrlEncoder().encodeToString(hashedBackupId(backupUser.backupId()));
+
+ backupManager.createMessageBackupUploadDescriptor(backupUser).join();
+ verify(tusCredentialGenerator, times(1))
+ .generateUpload(encodedBackupId, BackupManager.MESSAGE_BACKUP_NAME);
+
+ final BackupInfo info = backupManager.backupInfo(backupUser).join();
+ assertThat(info.backupSubdir()).isEqualTo(encodedBackupId);
+ assertThat(info.messageBackupKey()).isEqualTo(BackupManager.MESSAGE_BACKUP_NAME);
+ assertThat(info.mediaUsedSpace()).isEqualTo(Optional.empty());
+
+ // Check that the initial expiration times are the initial write times
+ checkExpectedExpirations(now, backupTier == BackupTier.MEDIA ? now : null, backupUser.backupId());
+ }
+
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"NONE"})
+ public void ttlRefresh(final BackupTier backupTier) throws InvalidInputException, VerificationFailedException {
+ final AuthenticatedBackupUser backupUser = backupUser(RandomUtils.nextBytes(16), backupTier);
+
+ final Instant tstart = Instant.ofEpochSecond(1).plus(Duration.ofDays(1));
+ final Instant tnext = tstart.plus(Duration.ofSeconds(1));
+
+ // create backup at t=tstart
+ testClock.pin(tstart);
+ backupManager.createMessageBackupUploadDescriptor(backupUser).join();
+
+ // refresh at t=tnext
+ testClock.pin(tnext);
+ backupManager.ttlRefresh(backupUser).join();
+
+ checkExpectedExpirations(
+ tnext,
+ backupTier == BackupTier.MEDIA ? tnext : null,
+ backupUser.backupId());
+ }
+
+ @ParameterizedTest
+ @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"NONE"})
+ public void createBackupRefreshesTtl(final BackupTier backupTier) throws VerificationFailedException {
+ final Instant tstart = Instant.ofEpochSecond(1).plus(Duration.ofDays(1));
+ final Instant tnext = tstart.plus(Duration.ofSeconds(1));
+
+ final AuthenticatedBackupUser backupUser = backupUser(RandomUtils.nextBytes(16), backupTier);
+
+ // create backup at t=tstart
+ testClock.pin(tstart);
+ backupManager.createMessageBackupUploadDescriptor(backupUser).join();
+
+ // create again at t=tnext
+ testClock.pin(tnext);
+ backupManager.createMessageBackupUploadDescriptor(backupUser).join();
+
+ checkExpectedExpirations(
+ tnext,
+ backupTier == BackupTier.MEDIA ? tnext : null,
+ backupUser.backupId());
+ }
+
+ @Test
+ public void unknownPublicKey() throws VerificationFailedException {
+ final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
+ BackupTier.MESSAGES, backupKey, aci);
+
+ final ECKeyPair keyPair = Curve.generateKeyPair();
+ final byte[] signature = keyPair.getPrivateKey().calculateSignature(presentation.serialize());
+
+ // haven't set a public key yet
+ assertThatExceptionOfType(StatusRuntimeException.class)
+ .isThrownBy(unwrapExceptions(() -> backupManager.authenticateBackupUser(presentation, signature)))
+ .extracting(ex -> ex.getStatus().getCode())
+ .isEqualTo(Status.NOT_FOUND.getCode());
+ }
+
+ @Test
+ public void mismatchedPublicKey() throws VerificationFailedException {
+ final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
+ BackupTier.MESSAGES, backupKey, aci);
+
+ final ECKeyPair keyPair1 = Curve.generateKeyPair();
+ final ECKeyPair keyPair2 = Curve.generateKeyPair();
+ final byte[] signature1 = keyPair1.getPrivateKey().calculateSignature(presentation.serialize());
+ final byte[] signature2 = keyPair2.getPrivateKey().calculateSignature(presentation.serialize());
+
+ backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey()).join();
+
+ // shouldn't be able to set a different public key
+ assertThatExceptionOfType(StatusRuntimeException.class)
+ .isThrownBy(unwrapExceptions(() -> backupManager.setPublicKey(presentation, signature2, keyPair2.getPublicKey())))
+ .extracting(ex -> ex.getStatus().getCode())
+ .isEqualTo(Status.UNAUTHENTICATED.getCode());
+
+ // should be able to set the same public key again (noop)
+ backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey()).join();
+ }
+
+ @Test
+ public void signatureValidation() throws VerificationFailedException {
+ final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
+ BackupTier.MESSAGES, backupKey, aci);
+
+ final ECKeyPair keyPair = Curve.generateKeyPair();
+ final byte[] signature = keyPair.getPrivateKey().calculateSignature(presentation.serialize());
+
+ // an invalid signature
+ final byte[] wrongSignature = Arrays.copyOf(signature, signature.length);
+ wrongSignature[1] += 1;
+
+ // shouldn't be able to set a public key with an invalid signature
+ assertThatExceptionOfType(StatusRuntimeException.class)
+ .isThrownBy(unwrapExceptions(() -> backupManager.setPublicKey(presentation, wrongSignature, keyPair.getPublicKey())))
+ .extracting(ex -> ex.getStatus().getCode())
+ .isEqualTo(Status.UNAUTHENTICATED.getCode());
+
+ backupManager.setPublicKey(presentation, signature, keyPair.getPublicKey()).join();
+
+ // shouldn't be able to authenticate with an invalid signature
+ assertThatExceptionOfType(StatusRuntimeException.class)
+ .isThrownBy(unwrapExceptions(() -> backupManager.authenticateBackupUser(presentation, wrongSignature)))
+ .extracting(ex -> ex.getStatus().getCode())
+ .isEqualTo(Status.UNAUTHENTICATED.getCode());
+
+ // correct signature
+ final AuthenticatedBackupUser user = backupManager.authenticateBackupUser(presentation, signature).join();
+ assertThat(user.backupId()).isEqualTo(presentation.getBackupId());
+ assertThat(user.backupTier()).isEqualTo(BackupTier.MESSAGES);
+ }
+
+ @Test
+ public void credentialExpiration() throws InvalidInputException, VerificationFailedException {
+
+ // credential for 1 day after epoch
+ testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(1)));
+ final BackupAuthCredentialPresentation oldCredential = backupAuthTestUtil.getPresentation(BackupTier.MESSAGES, backupKey, aci);
+ final ECKeyPair keyPair = Curve.generateKeyPair();
+ final byte[] signature = keyPair.getPrivateKey().calculateSignature(oldCredential.serialize());
+ backupManager.setPublicKey(oldCredential, signature, keyPair.getPublicKey()).join();
+
+ // should be accepted the day before to forgive clock skew
+ testClock.pin(Instant.ofEpochSecond(1));
+ assertThatNoException().isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature).join());
+
+ // should be accepted the day after to forgive clock skew
+ testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(2)));
+ assertThatNoException().isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature).join());
+
+ // should be rejected the day after that
+ testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(3)));
+ assertThatExceptionOfType(StatusRuntimeException.class)
+ .isThrownBy(unwrapExceptions(() -> backupManager.authenticateBackupUser(oldCredential, signature)))
+ .extracting(ex -> ex.getStatus().getCode())
+ .isEqualTo(Status.UNAUTHENTICATED.getCode());
+ }
+
+ private void checkExpectedExpirations(
+ final Instant expectedExpiration,
+ final @Nullable Instant expectedMediaExpiration,
+ final byte[] backupId) {
+ final GetItemResponse item = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()
+ .tableName(DynamoDbExtensionSchema.Tables.BACKUPS.tableName())
+ .key(Map.of(BackupManager.KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupId))))
+ .build());
+ assertThat(item.hasItem()).isTrue();
+ final Instant refresh = Instant.ofEpochSecond(Long.parseLong(item.item().get(BackupManager.ATTR_LAST_REFRESH).n()));
+ assertThat(refresh).isEqualTo(expectedExpiration);
+
+ if (expectedMediaExpiration == null) {
+ assertThat(item.item()).doesNotContainKey(BackupManager.ATTR_LAST_MEDIA_REFRESH);
+ } else {
+ assertThat(Instant.ofEpochSecond(Long.parseLong(item.item().get(BackupManager.ATTR_LAST_MEDIA_REFRESH).n())))
+ .isEqualTo(expectedMediaExpiration);
+ }
+ }
+
+ 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 AuthenticatedBackupUser backupUser(final byte[] backupId, final BackupTier backupTier) {
+ return new AuthenticatedBackupUser(backupId, backupTier);
+ }
+
+ private ThrowableAssert.ThrowingCallable unwrapExceptions(final Supplier> f) {
+ return () -> {
+ try {
+ f.get().join();
+ } catch (Exception e) {
+ if (ExceptionUtils.unwrap(e) instanceof StatusRuntimeException ex) {
+ throw ex;
+ }
+ throw e;
+ }
+ };
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/backup/TusBackupCredentialGeneratorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/backup/TusBackupCredentialGeneratorTest.java
new file mode 100644
index 000000000..993a370f6
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/backup/TusBackupCredentialGeneratorTest.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.backup;
+
+import org.apache.commons.lang3.RandomUtils;
+import org.junit.jupiter.api.Test;
+import org.whispersystems.textsecuregcm.attachments.TusConfiguration;
+import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class TusBackupCredentialGeneratorTest {
+ @Test
+ public void uploadGenerator() {
+ TusBackupCredentialGenerator generator = new TusBackupCredentialGenerator(new TusConfiguration(
+ new SecretBytes(RandomUtils.nextBytes(32)),
+ "https://example.org/upload"));
+
+ final MessageBackupUploadDescriptor messageBackupUploadDescriptor = generator.generateUpload("subdir", "key");
+ assertThat(messageBackupUploadDescriptor.signedUploadLocation()).isEqualTo("https://example.org/upload/backups");
+ assertThat(messageBackupUploadDescriptor.key()).isEqualTo("subdir/key");
+ assertThat(messageBackupUploadDescriptor.headers()).containsKey("Authorization");
+ final String username = parseUsername(messageBackupUploadDescriptor.headers().get("Authorization"));
+ assertThat(username).isEqualTo("write$backups/subdir/key");
+ }
+
+ @Test
+ public void readCredential() {
+ TusBackupCredentialGenerator generator = new TusBackupCredentialGenerator(new TusConfiguration(
+ new SecretBytes(RandomUtils.nextBytes(32)),
+ "https://example.org/upload"));
+
+ final Map headers = generator.readHeaders("subdir");
+ assertThat(headers).containsKey("Authorization");
+ final String username = parseUsername(headers.get("Authorization"));
+ assertThat(username).isEqualTo("read$backups/subdir");
+ }
+
+ private static String parseUsername(final String authHeader) {
+ assertThat(authHeader).startsWith("Basic");
+ final String encoded = authHeader.substring("Basic".length() + 1);
+ final String cred = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8);
+ return cred.split(":")[0];
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java
new file mode 100644
index 000000000..88299afe3
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.controllers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableSet;
+import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
+import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
+import io.dropwizard.testing.junit5.ResourceExtension;
+import io.grpc.Status;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Base64;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Stream;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.Invocation;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.RandomUtils;
+import org.glassfish.jersey.server.ServerProperties;
+import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.signal.libsignal.protocol.ecc.Curve;
+import org.signal.libsignal.zkgroup.VerificationFailedException;
+import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;
+import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
+import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
+import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
+import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
+import org.whispersystems.textsecuregcm.backup.BackupAuthTestUtil;
+import org.whispersystems.textsecuregcm.backup.BackupManager;
+import org.whispersystems.textsecuregcm.backup.BackupTier;
+import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
+import org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;
+import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
+import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
+import org.whispersystems.textsecuregcm.util.SystemMapper;
+
+@ExtendWith(DropwizardExtensionsSupport.class)
+public class ArchiveControllerTest {
+
+ private static final BackupAuthManager backupAuthManager = mock(BackupAuthManager.class);
+ private static final BackupManager backupManager = mock(BackupManager.class);
+ private final BackupAuthTestUtil backupAuthTestUtil = new BackupAuthTestUtil(Clock.systemUTC());
+
+ private static final ResourceExtension resources = ResourceExtension.builder()
+ .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
+ .addProvider(AuthHelper.getAuthFilter())
+ .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(
+ ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
+ .addProvider(new CompletionExceptionMapper())
+ .addResource(new GrpcStatusRuntimeExceptionMapper())
+ .addProvider(new RateLimitExceededExceptionMapper())
+ .setMapper(SystemMapper.jsonMapper())
+ .setTestContainerFactory(new GrizzlyWebTestContainerFactory())
+ .addResource(new ArchiveController(backupAuthManager, backupManager))
+ .build();
+
+ private final UUID aci = UUID.randomUUID();
+ private final byte[] backupKey = RandomUtils.nextBytes(32);
+
+ @BeforeEach
+ public void setUp() {
+ reset(backupAuthManager);
+ reset(backupManager);
+ }
+
+ @ParameterizedTest
+ @CsvSource(textBlock = """
+ GET, v1/archives/auth/read,
+ GET, v1/archives/,
+ GET, v1/archives/upload/form,
+ POST, v1/archives/,
+ PUT, v1/archives/keys, '{"backupIdPublicKey": "aaaaa"}'
+ """)
+ public void anonymousAuthOnly(final String method, final String path, final String body)
+ throws VerificationFailedException {
+ final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
+ BackupTier.MEDIA, backupKey, aci);
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target(path)
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
+ .header("X-Signal-ZK-Auth", Base64.getEncoder().encodeToString(presentation.serialize()))
+ .header("X-Signal-ZK-Auth-Signature",
+ Base64.getEncoder().encodeToString("abc".getBytes(StandardCharsets.UTF_8)));
+
+ final Response response;
+ if (body != null) {
+ response = request.method(method, Entity.entity(body, MediaType.APPLICATION_JSON_TYPE));
+ } else {
+ response = request.method(method);
+ }
+ assertThat(response.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ }
+
+ @Test
+ public void setBackupId() throws RateLimitExceededException {
+ when(backupAuthManager.commitBackupId(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
+
+ final Response response = resources.getJerseyTest()
+ .target("v1/archives/backupid")
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
+ .put(Entity.entity(new ArchiveController.SetBackupIdRequest(backupAuthTestUtil.getRequest(backupKey, aci)),
+ MediaType.APPLICATION_JSON_TYPE));
+ assertThat(response.getStatus()).isEqualTo(204);
+ }
+
+ @Test
+ public void setBadPublicKey() throws VerificationFailedException {
+ when(backupManager.setPublicKey(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
+
+ final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
+ BackupTier.MEDIA, backupKey, aci);
+ final Response response = resources.getJerseyTest()
+ .target("v1/archives/keys")
+ .request()
+ .header("X-Signal-ZK-Auth", Base64.getEncoder().encodeToString(presentation.serialize()))
+ .header("X-Signal-ZK-Auth-Signature", "aaa")
+ .put(Entity.entity("""
+ {"backupIdPublicKey": "aaaaa"}
+ """, MediaType.APPLICATION_JSON_TYPE));
+ assertThat(response.getStatus()).isEqualTo(400);
+ }
+
+ @Test
+ public void setPublicKey() throws VerificationFailedException {
+ when(backupManager.setPublicKey(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
+
+ final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
+ BackupTier.MEDIA, backupKey, aci);
+ final Response response = resources.getJerseyTest()
+ .target("v1/archives/keys")
+ .request()
+ .header("X-Signal-ZK-Auth", Base64.getEncoder().encodeToString(presentation.serialize()))
+ .header("X-Signal-ZK-Auth-Signature", "aaa")
+ .put(Entity.entity(
+ new ArchiveController.SetPublicKeyRequest(Curve.generateKeyPair().getPublicKey()),
+ MediaType.APPLICATION_JSON_TYPE));
+ assertThat(response.getStatus()).isEqualTo(204);
+ }
+
+
+ @ParameterizedTest
+ @CsvSource(textBlock = """
+ {}, 422
+ '{"backupAuthCredentialRequest": "aaa"}', 400
+ '{"backupAuthCredentialRequest": ""}', 400
+ """)
+ public void setBackupIdInvalid(final String requestBody, final int expectedStatus) {
+ final Response response = resources.getJerseyTest()
+ .target("v1/archives/backupid")
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
+ .put(Entity.entity(requestBody, MediaType.APPLICATION_JSON_TYPE));
+ assertThat(response.getStatus()).isEqualTo(expectedStatus);
+ }
+
+ public static Stream setBackupIdException() {
+ return Stream.of(
+ Arguments.of(new RateLimitExceededException(null, false), false, 429),
+ Arguments.of(Status.INVALID_ARGUMENT.withDescription("async").asRuntimeException(), false, 400),
+ Arguments.of(Status.INVALID_ARGUMENT.withDescription("sync").asRuntimeException(), true, 400)
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ public void setBackupIdException(final Exception ex, final boolean sync, final int expectedStatus)
+ throws RateLimitExceededException {
+ if (sync) {
+ when(backupAuthManager.commitBackupId(any(), any())).thenThrow(ex);
+ } else {
+ when(backupAuthManager.commitBackupId(any(), any())).thenReturn(CompletableFuture.failedFuture(ex));
+ }
+ final Response response = resources.getJerseyTest()
+ .target("v1/archives/backupid")
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
+ .put(Entity.entity(new ArchiveController.SetBackupIdRequest(backupAuthTestUtil.getRequest(backupKey, aci)),
+ MediaType.APPLICATION_JSON_TYPE));
+ assertThat(response.getStatus()).isEqualTo(expectedStatus);
+ }
+
+ @Test
+ public void getCredentials() {
+ final Instant start = Instant.now().truncatedTo(ChronoUnit.DAYS);
+ final Instant end = start.plus(Duration.ofDays(1));
+ final List expectedResponse = backupAuthTestUtil.getCredentials(
+ BackupTier.MEDIA, backupAuthTestUtil.getRequest(backupKey, aci), start, end);
+ when(backupAuthManager.getBackupAuthCredentials(any(), eq(start), eq(end))).thenReturn(
+ CompletableFuture.completedFuture(expectedResponse));
+ final ArchiveController.BackupAuthCredentialsResponse creds = resources.getJerseyTest()
+ .target("v1/archives/auth")
+ .queryParam("redemptionStartSeconds", start.getEpochSecond())
+ .queryParam("redemptionEndSeconds", end.getEpochSecond())
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
+ .get(ArchiveController.BackupAuthCredentialsResponse.class);
+ assertThat(creds.credentials().get(0).redemptionTime()).isEqualTo(start.getEpochSecond());
+ }
+
+ enum BadCredentialsType {MISSING_START, MISSING_END, MISSING_BOTH}
+
+ @ParameterizedTest
+ @EnumSource
+ public void getCredentialsBadInput(final BadCredentialsType badCredentialsType) {
+ WebTarget builder = resources.getJerseyTest()
+ .target("v1/archives/auth");
+
+ final Instant start = Instant.now().truncatedTo(ChronoUnit.DAYS);
+ final Instant end = start.plus(Duration.ofDays(1));
+ if (badCredentialsType != BadCredentialsType.MISSING_BOTH
+ && badCredentialsType != BadCredentialsType.MISSING_START) {
+ builder = builder.queryParam("redemptionStartSeconds", start.getEpochSecond());
+ }
+ if (badCredentialsType != BadCredentialsType.MISSING_BOTH && badCredentialsType != BadCredentialsType.MISSING_END) {
+ builder = builder.queryParam("redemptionEndSeconds", end.getEpochSecond());
+ }
+ final Response response = builder
+ .request()
+ .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
+ .method("GET");
+ assertThat(response.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ }
+
+ @Test
+ public void getBackupInfo() throws VerificationFailedException {
+ final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(
+ BackupTier.MEDIA, backupKey, aci);
+ when(backupManager.authenticateBackupUser(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new AuthenticatedBackupUser(presentation.getBackupId(), BackupTier.MEDIA)));
+ when(backupManager.backupInfo(any())).thenReturn(CompletableFuture.completedFuture(new BackupManager.BackupInfo(
+ 1, "subdir", "filename", Optional.empty())));
+ final ArchiveController.BackupInfoResponse response = resources.getJerseyTest()
+ .target("v1/archives")
+ .request()
+ .header("X-Signal-ZK-Auth", Base64.getEncoder().encodeToString(presentation.serialize()))
+ .header("X-Signal-ZK-Auth-Signature", "aaa")
+ .get(ArchiveController.BackupInfoResponse.class);
+ assertThat(response.backupDir()).isEqualTo("subdir");
+ assertThat(response.backupName()).isEqualTo("filename");
+ assertThat(response.cdn()).isEqualTo(1);
+ assertThat(response.usedSpace()).isNull();
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/mappers/GrpcStatusRuntimeExceptionMapperTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/mappers/GrpcStatusRuntimeExceptionMapperTest.java
new file mode 100644
index 000000000..1219e1924
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/mappers/GrpcStatusRuntimeExceptionMapperTest.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2013 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.mappers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import io.dropwizard.jersey.errors.ErrorMessage;
+import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
+import io.dropwizard.testing.junit5.ResourceExtension;
+import io.grpc.Status;
+import java.util.stream.Stream;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.glassfish.jersey.server.ServerProperties;
+import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.whispersystems.textsecuregcm.util.SystemMapper;
+
+@ExtendWith(DropwizardExtensionsSupport.class)
+class GrpcStatusRuntimeExceptionMapperTest {
+
+ private static final GrpcStatusRuntimeExceptionMapper exceptionMapper = new GrpcStatusRuntimeExceptionMapper();
+ private static final TestController testController = new TestController();
+
+ private static final ResourceExtension resources = ResourceExtension.builder()
+ .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
+ .addProvider(new CompletionExceptionMapper())
+ .addProvider(exceptionMapper)
+ .setTestContainerFactory(new GrizzlyWebTestContainerFactory())
+ .addResource(testController)
+ .build();
+
+ @BeforeEach
+ public void setUp() {
+ testController.exception = null;
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "text"})
+ public void responseBody(final String path) throws JsonProcessingException {
+ testController.exception = Status.INVALID_ARGUMENT.withDescription("oofta").asRuntimeException();
+ final Response response = resources.getJerseyTest().target("/v1/test/" + path).request().get();
+ assertThat(response.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ final ErrorMessage body = SystemMapper.jsonMapper().readValue(
+ response.readEntity(String.class),
+ ErrorMessage.class);
+
+ assertThat(body.getMessage()).isEqualTo(testController.exception.getMessage());
+ assertThat(body.getCode()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+ }
+
+ public static Stream errorMapping() {
+ return Stream.of(
+ Arguments.of(Status.INVALID_ARGUMENT, 400),
+ Arguments.of(Status.NOT_FOUND, 404),
+ Arguments.of(Status.UNAVAILABLE, 500));
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ public void errorMapping(final Status status, final int expectedHttpCode) {
+ testController.exception = status.asRuntimeException();
+ final Response response = resources.getJerseyTest().target("/v1/test/json").request().get();
+ assertThat(response.getStatus()).isEqualTo(expectedHttpCode);
+ }
+
+ @Path("/v1/test")
+ public static class TestController {
+
+ volatile RuntimeException exception = null;
+
+ @GET
+ @Path("/text")
+ public Response plaintext() {
+ if (exception != null) {
+ throw exception;
+ }
+ return Response.ok().build();
+ }
+
+ @GET
+ @Path("/json")
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response json() {
+ if (exception != null) {
+ throw exception;
+ }
+ return Response.ok().build();
+ }
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java
index d167368b6..c301cb425 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java
@@ -5,7 +5,9 @@
package org.whispersystems.textsecuregcm.storage;
+import java.util.Collections;
import java.util.List;
+import org.whispersystems.textsecuregcm.backup.BackupManager;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
@@ -47,6 +49,14 @@ public final class DynamoDbExtensionSchema {
),
List.of()),
+ BACKUPS("backups_test",
+ BackupManager.KEY_BACKUP_ID_HASH,
+ null,
+ List.of(AttributeDefinition.builder()
+ .attributeName(BackupManager.KEY_BACKUP_ID_HASH)
+ .attributeType(ScalarAttributeType.B).build()),
+ Collections.emptyList(), Collections.emptyList()),
+
CLIENT_RELEASES("client_releases_test",
ClientReleases.ATTR_PLATFORM,
ClientReleases.ATTR_VERSION,
@@ -84,7 +94,7 @@ public final class DynamoDbExtensionSchema {
.build()),
List.of()
),
-
+
DELETED_ACCOUNTS_LOCK("deleted_accounts_lock_test",
AccountLockManager.KEY_ACCOUNT_E164,
null,
@@ -92,7 +102,7 @@ public final class DynamoDbExtensionSchema {
.attributeName(AccountLockManager.KEY_ACCOUNT_E164)
.attributeType(ScalarAttributeType.S).build()),
List.of(), List.of()),
-
+
NUMBERS("numbers_test",
Accounts.ATTR_ACCOUNT_E164,
null,
@@ -233,7 +243,7 @@ public final class DynamoDbExtensionSchema {
.attributeType(ScalarAttributeType.S)
.build()),
List.of(), List.of()),
-
+
PUSH_CHALLENGES("push_challenge_test",
PushChallengeDynamoDb.KEY_ACCOUNT_UUID,
null,
@@ -251,7 +261,7 @@ public final class DynamoDbExtensionSchema {
.attributeType(ScalarAttributeType.B)
.build()),
List.of(), List.of()),
-
+
REGISTRATION_RECOVERY_PASSWORDS("registration_recovery_passwords_test",
RegistrationRecoveryPasswords.KEY_E164,
null,
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ExperimentHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ExperimentHelper.java
new file mode 100644
index 000000000..e88fb86c6
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ExperimentHelper.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.tests.util;
+
+import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
+import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration;
+import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ExperimentHelper {
+
+ public static DynamicConfigurationManager withEnrollment(
+ final String experimentName,
+ final Set enrolledUuids,
+ final int enrollmentPercentage) {
+ final DynamicConfigurationManager dcm = mock(DynamicConfigurationManager.class);
+ final DynamicConfiguration dc = mock(DynamicConfiguration.class);
+ when(dcm.getConfiguration()).thenReturn(dc);
+ final DynamicExperimentEnrollmentConfiguration exp = mock(DynamicExperimentEnrollmentConfiguration.class);
+ when(dc.getExperimentEnrollmentConfiguration(experimentName)).thenReturn(Optional.of(exp));
+ when(exp.getEnrolledUuids()).thenReturn(enrolledUuids);
+ when(exp.getEnrollmentPercentage()).thenReturn(enrollmentPercentage);
+ return dcm;
+ }
+
+ public static DynamicConfigurationManager withEnrollment(final String experimentName, final Set enrolledUuids) {
+ return withEnrollment(experimentName, enrolledUuids, 0);
+ }
+
+ public static DynamicConfigurationManager withEnrollment(final String experimentName, final UUID enrolledUuid) {
+ return withEnrollment(experimentName, Set.of(enrolledUuid), 0);
+ }
+}