diff --git a/pom.xml b/pom.xml index 9565fcd3b..d14b0bf08 100644 --- a/pom.xml +++ b/pom.xml @@ -276,7 +276,7 @@ org.signal libsignal-server - 0.30.0 + 0.33.0 org.apache.logging.log4j diff --git a/service/config/sample-secrets-bundle.yml b/service/config/sample-secrets-bundle.yml index 123704c88..65b577f0d 100644 --- a/service/config/sample-secrets-bundle.yml +++ b/service/config/sample-secrets-bundle.yml @@ -72,6 +72,8 @@ storageService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAA zkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA== genericZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA== +callingZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA== +backupsZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA== paymentsService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # base64-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users paymentsService.fixerApiKey: unset diff --git a/service/config/sample.yml b/service/config/sample.yml index 810d2ea7f..02afbf74b 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -85,6 +85,8 @@ dynamoDbTables: phoneNumberTableName: Example_Accounts_PhoneNumbers phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers usernamesTableName: Example_Accounts_Usernames + backups: + tableName: Example_Backups clientReleases: tableName: Example_ClientReleases deletedAccounts: @@ -266,8 +268,11 @@ zkConfig: serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz serverSecret: secret://zkConfig.serverSecret -genericZkConfig: - serverSecret: secret://genericZkConfig.serverSecret +callingZkConfig: + serverSecret: secret://callingZkConfig.serverSecret + +backupsZkConfig: + serverSecret: secret://backupsZkConfig.serverSecret appConfig: application: example diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 4116cb9cb..e0c15a7d5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -224,7 +224,12 @@ public class WhisperServerConfiguration extends Configuration { @Valid @NotNull @JsonProperty - private GenericZkConfig genericZkConfig; + private GenericZkConfig callingZkConfig; + + @Valid + @NotNull + @JsonProperty + private GenericZkConfig backupsZkConfig; @Valid @NotNull @@ -435,8 +440,12 @@ public class WhisperServerConfiguration extends Configuration { return zkConfig; } - public GenericZkConfig getGenericZkConfig() { - return genericZkConfig; + public GenericZkConfig getCallingZkConfig() { + return callingZkConfig; + } + + public GenericZkConfig getBackupsZkConfig() { + return backupsZkConfig; } public RemoteConfigConfiguration getRemoteConfigConfiguration() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 1e7640af8..14e42f91d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -76,6 +76,9 @@ import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener; import org.whispersystems.textsecuregcm.auth.grpc.BasicCredentialAuthenticationInterceptor; +import org.whispersystems.textsecuregcm.backup.BackupAuthManager; +import org.whispersystems.textsecuregcm.backup.BackupManager; +import org.whispersystems.textsecuregcm.backup.TusBackupCredentialGenerator; import org.whispersystems.textsecuregcm.badges.ConfiguredProfileBadgeConverter; import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator; import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; @@ -92,6 +95,7 @@ import org.whispersystems.textsecuregcm.controllers.ArtController; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4; +import org.whispersystems.textsecuregcm.controllers.ArchiveController; import org.whispersystems.textsecuregcm.controllers.CallLinkController; import org.whispersystems.textsecuregcm.controllers.CertificateController; import org.whispersystems.textsecuregcm.controllers.ChallengeController; @@ -137,6 +141,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper; +import org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper; import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper; import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper; import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper; @@ -638,11 +643,18 @@ public class WhisperServerService extends Application accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator( accountAuthenticator).buildAuthFilter(); AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator( @@ -767,8 +779,9 @@ public class WhisperServerService extends Application + * 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); + } +}