mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 11:28:05 +01:00
Add ArchiveController
Adds endpoints for creating and managing backup objects with ZK anonymous credentials.
This commit is contained in:
committed by
ravi-signal
parent
ba139dddd8
commit
6b38b538f1
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import io.grpc.Status;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Stream;
|
||||
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
/**
|
||||
* Issues ZK backup auth credentials for authenticated accounts
|
||||
* <p>
|
||||
* Authenticated callers can create ZK credentials that contain a blinded backup-id, so that they can later use that
|
||||
* backup id without the verifier learning that the id is associated with this account.
|
||||
* <p>
|
||||
* First use {@link #commitBackupId} to provide a blinded backup-id. This is stored in durable storage. Then the caller
|
||||
* can use {@link #getBackupAuthCredentials} to retrieve credentials that can subsequently be used to make anonymously
|
||||
* authenticated requests against their backup-id.
|
||||
*/
|
||||
public class BackupAuthManager {
|
||||
|
||||
private static final Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);
|
||||
final static String BACKUP_EXPERIMENT_NAME = "backup";
|
||||
final static String BACKUP_MEDIA_EXPERIMENT_NAME = "backupMedia";
|
||||
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
private final GenericServerSecretParams serverSecretParams;
|
||||
private final Clock clock;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
public BackupAuthManager(
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
final RateLimiters rateLimiters,
|
||||
final AccountsManager accountsManager,
|
||||
final GenericServerSecretParams serverSecretParams,
|
||||
final Clock clock) {
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.accountsManager = accountsManager;
|
||||
this.serverSecretParams = serverSecretParams;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a credential request containing a blinded backup-id for future use.
|
||||
*
|
||||
* @param account The account using the backup-id
|
||||
* @param backupAuthCredentialRequest A request containing the blinded backup-id
|
||||
* @return A future that completes when the credentialRequest has been stored
|
||||
* @throws RateLimitExceededException If too many backup-ids have been committed
|
||||
*/
|
||||
public CompletableFuture<Void> commitBackupId(final Account account,
|
||||
final BackupAuthCredentialRequest backupAuthCredentialRequest) throws RateLimitExceededException {
|
||||
if (receiptLevel(account).isEmpty()) {
|
||||
throw Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException();
|
||||
}
|
||||
|
||||
byte[] serializedRequest = backupAuthCredentialRequest.serialize();
|
||||
byte[] existingRequest = account.getBackupCredentialRequest();
|
||||
if (existingRequest != null && MessageDigest.isEqual(serializedRequest, existingRequest)) {
|
||||
// No need to update or enforce rate limits, this is the credential that the user has already
|
||||
// committed to.
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID).validate(account.getUuid());
|
||||
|
||||
return this.accountsManager
|
||||
.updateAsync(account, acc -> acc.setBackupCredentialRequest(serializedRequest))
|
||||
.thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
public record Credential(BackupAuthCredentialResponse credential, Instant redemptionTime) {}
|
||||
|
||||
/**
|
||||
* Create a credential for every day between redemptionStart and redemptionEnd
|
||||
* <p>
|
||||
* This uses a {@link BackupAuthCredentialRequest} previous stored via {@link this#commitBackupId} to generate the
|
||||
* credentials.
|
||||
*
|
||||
* @param account The account to create the credentials for
|
||||
* @param redemptionStart The day (must be truncated to a day boundary) the first credential should be valid
|
||||
* @param redemptionEnd The day (must be truncated to a day boundary) the last credential should be valid
|
||||
* @return Credentials and the day on which they may be redeemed
|
||||
*/
|
||||
public CompletableFuture<List<Credential>> getBackupAuthCredentials(
|
||||
final Account account,
|
||||
final Instant redemptionStart,
|
||||
final Instant redemptionEnd) {
|
||||
|
||||
final long receiptLevel = receiptLevel(account).orElseThrow(
|
||||
() -> Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException());
|
||||
|
||||
final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
if (redemptionStart.isAfter(redemptionEnd) ||
|
||||
redemptionStart.isBefore(startOfDay) ||
|
||||
redemptionEnd.isAfter(startOfDay.plus(MAX_REDEMPTION_DURATION)) ||
|
||||
!redemptionStart.equals(redemptionStart.truncatedTo(ChronoUnit.DAYS)) ||
|
||||
!redemptionEnd.equals(redemptionEnd.truncatedTo(ChronoUnit.DAYS))) {
|
||||
|
||||
throw Status.INVALID_ARGUMENT.withDescription("invalid redemption window").asRuntimeException();
|
||||
}
|
||||
|
||||
// fetch the blinded backup-id the account should have previously committed to
|
||||
final byte[] committedBytes = account.getBackupCredentialRequest();
|
||||
if (committedBytes == null) {
|
||||
throw Status.NOT_FOUND.withDescription("No blinded backup-id has been added to the account").asRuntimeException();
|
||||
}
|
||||
|
||||
try {
|
||||
// create a credential for every day in the requested period
|
||||
final BackupAuthCredentialRequest credentialReq = new BackupAuthCredentialRequest(committedBytes);
|
||||
return CompletableFuture.completedFuture(Stream
|
||||
.iterate(redemptionStart, curr -> curr.plus(Duration.ofDays(1)))
|
||||
.takeWhile(redemptionTime -> !redemptionTime.isAfter(redemptionEnd))
|
||||
.map(redemption -> new Credential(
|
||||
credentialReq.issueCredential(redemption, receiptLevel, serverSecretParams),
|
||||
redemption))
|
||||
.toList());
|
||||
} catch (InvalidInputException e) {
|
||||
throw Status.INTERNAL
|
||||
.withDescription("Could not deserialize stored request credential")
|
||||
.withCause(e)
|
||||
.asRuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<Long> receiptLevel(final Account account) {
|
||||
if (inExperiment(BACKUP_MEDIA_EXPERIMENT_NAME, account)) {
|
||||
return Optional.of(BackupTier.MEDIA.getReceiptLevel());
|
||||
}
|
||||
if (inExperiment(BACKUP_EXPERIMENT_NAME, account)) {
|
||||
return Optional.of(BackupTier.MESSAGES.getReceiptLevel());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private boolean inExperiment(final String experimentName, final Account account) {
|
||||
return dynamicConfigurationManager.getConfiguration()
|
||||
.getExperimentEnrollmentConfiguration(experimentName)
|
||||
.map(config -> config.getEnrolledUuids().contains(account.getUuid()))
|
||||
.orElse(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import io.grpc.Status;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
||||
|
||||
public class BackupManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BackupManager.class);
|
||||
|
||||
static final String MESSAGE_BACKUP_NAME = "messageBackup";
|
||||
private static final int BACKUP_CDN = 3;
|
||||
private static final String ZK_AUTHN_COUNTER_NAME = MetricsUtil.name(BackupManager.class, "authentication");
|
||||
private static final String ZK_AUTHZ_FAILURE_COUNTER_NAME = MetricsUtil.name(BackupManager.class, "authorizationFailure");
|
||||
private static final String SUCCESS_TAG_NAME = "success";
|
||||
private static final String FAILURE_REASON_TAG_NAME = "reason";
|
||||
|
||||
private final GenericServerSecretParams serverSecretParams;
|
||||
private final TusBackupCredentialGenerator tusBackupCredentialGenerator;
|
||||
private final DynamoDbAsyncClient dynamoClient;
|
||||
private final String backupTableName;
|
||||
private final Clock clock;
|
||||
|
||||
// The backups table
|
||||
|
||||
// B: 16 bytes that identifies the backup
|
||||
public static final String KEY_BACKUP_ID_HASH = "U";
|
||||
// N: Time in seconds since epoch of the last backup refresh. This timestamp must be periodically updated to avoid
|
||||
// garbage collection of archive objects.
|
||||
public static final String ATTR_LAST_REFRESH = "R";
|
||||
// N: Time in seconds since epoch of the last backup media refresh. This timestamp can only be updated if the client
|
||||
// has BackupTier.MEDIA, and must be periodically updated to avoid garbage collection of media objects.
|
||||
public static final String ATTR_LAST_MEDIA_REFRESH = "MR";
|
||||
// B: A 32 byte public key that should be used to sign the presentation used to authenticate requests against the
|
||||
// backup-id
|
||||
public static final String ATTR_PUBLIC_KEY = "P";
|
||||
// N: Bytes consumed by this backup
|
||||
public static final String ATTR_MEDIA_BYTES_USED = "MB";
|
||||
// N: Number of media objects in the backup
|
||||
public static final String ATTR_MEDIA_COUNT = "MC";
|
||||
// N: The cdn number where the message backup is stored
|
||||
public static final String ATTR_CDN = "CDN";
|
||||
|
||||
public BackupManager(
|
||||
final GenericServerSecretParams serverSecretParams,
|
||||
final TusBackupCredentialGenerator tusBackupCredentialGenerator,
|
||||
final DynamoDbAsyncClient dynamoClient,
|
||||
final String backupTableName,
|
||||
final Clock clock) {
|
||||
this.serverSecretParams = serverSecretParams;
|
||||
this.dynamoClient = dynamoClient;
|
||||
this.tusBackupCredentialGenerator = tusBackupCredentialGenerator;
|
||||
this.backupTableName = backupTableName;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the public key for the backup-id.
|
||||
* <p>
|
||||
* Once set, calls {@link BackupManager#authenticateBackupUser} can succeed if the presentation is signed with the
|
||||
* private key corresponding to this public key.
|
||||
*
|
||||
* @param presentation a ZK credential presentation that encodes the backupId
|
||||
* @param signature the signature of the presentation
|
||||
* @param publicKey the public key of a key-pair that the presentation must be signed with
|
||||
*/
|
||||
public CompletableFuture<Void> setPublicKey(
|
||||
final BackupAuthCredentialPresentation presentation,
|
||||
final byte[] signature,
|
||||
final ECPublicKey publicKey) {
|
||||
|
||||
// Note: this is a special case where we can't validate the presentation signature against the stored public key
|
||||
// because we are currently setting it. We check against the provided public key, but we must also verify that
|
||||
// there isn't an existing, different stored public key for the backup-id (verified with a condition expression)
|
||||
final BackupTier backupTier = verifySignatureAndCheckPresentation(presentation, signature, publicKey);
|
||||
if (backupTier.compareTo(BackupTier.MESSAGES) < 0) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
|
||||
throw Status.PERMISSION_DENIED
|
||||
.withDescription("credential does not support setting public key")
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
final byte[] hashedBackupId = hashedBackupId(presentation.getBackupId());
|
||||
return dynamoClient.updateItem(UpdateItemRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
|
||||
.updateExpression("SET #publicKey = :publicKey")
|
||||
.expressionAttributeNames(Map.of("#publicKey", ATTR_PUBLIC_KEY))
|
||||
.expressionAttributeValues(Map.of(":publicKey", AttributeValues.b(publicKey.serialize())))
|
||||
.conditionExpression("attribute_not_exists(#publicKey) OR #publicKey = :publicKey")
|
||||
.build())
|
||||
.exceptionally(throwable -> {
|
||||
// There was already a row for this backup-id and it contained a different publicKey
|
||||
if (ExceptionUtils.unwrap(throwable) instanceof ConditionalCheckFailedException) {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
FAILURE_REASON_TAG_NAME, "public_key_conflict")
|
||||
.increment();
|
||||
throw Status.UNAUTHENTICATED
|
||||
.withDescription("public key does not match existing public key for the backup-id")
|
||||
.asRuntimeException();
|
||||
}
|
||||
throw ExceptionUtils.wrap(throwable);
|
||||
})
|
||||
.thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a form that may be used to upload a backup file for the backupId encoded in the presentation.
|
||||
* <p>
|
||||
* If successful, this also updates the TTL of the backup.
|
||||
*
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
* @return the upload form
|
||||
*/
|
||||
public CompletableFuture<MessageBackupUploadDescriptor> createMessageBackupUploadDescriptor(
|
||||
final AuthenticatedBackupUser backupUser) {
|
||||
final byte[] hashedBackupId = hashedBackupId(backupUser);
|
||||
final String encodedBackupId = encodeForCdn(hashedBackupId);
|
||||
|
||||
final long refreshTimeSecs = clock.instant().getEpochSecond();
|
||||
|
||||
final List<String> updates = new ArrayList<>(List.of("#cdn = :cdn", "#lastRefresh = :expiration"));
|
||||
final Map<String, String> expressionAttributeNames = new HashMap<>(Map.of(
|
||||
"#cdn", ATTR_CDN,
|
||||
"#lastRefresh", ATTR_LAST_REFRESH));
|
||||
if (backupUser.backupTier().compareTo(BackupTier.MEDIA) >= 0) {
|
||||
updates.add("#lastMediaRefresh = :expiration");
|
||||
expressionAttributeNames.put("#lastMediaRefresh", ATTR_LAST_MEDIA_REFRESH);
|
||||
}
|
||||
|
||||
// this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp
|
||||
return dynamoClient.updateItem(UpdateItemRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
|
||||
.updateExpression("SET %s".formatted(String.join(",", updates)))
|
||||
.expressionAttributeNames(expressionAttributeNames)
|
||||
.expressionAttributeValues(Map.of(
|
||||
":cdn", AttributeValues.n(BACKUP_CDN),
|
||||
":expiration", AttributeValues.n(refreshTimeSecs)))
|
||||
.build())
|
||||
.thenApply(result -> tusBackupCredentialGenerator.generateUpload(encodedBackupId, MESSAGE_BACKUP_NAME));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last update timestamps for the backupId in the presentation
|
||||
*
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
*/
|
||||
public CompletableFuture<Void> ttlRefresh(final AuthenticatedBackupUser backupUser) {
|
||||
if (backupUser.backupTier().compareTo(BackupTier.MESSAGES) < 0) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
|
||||
throw Status.PERMISSION_DENIED
|
||||
.withDescription("credential does not support ttl operation")
|
||||
.asRuntimeException();
|
||||
}
|
||||
final long refreshTimeSecs = clock.instant().getEpochSecond();
|
||||
// update message backup TTL
|
||||
final List<String> updates = new ArrayList<>(Collections.singletonList("#lastRefresh = :expiration"));
|
||||
final Map<String, String> expressionAttributeNames = new HashMap<>(Map.of("#lastRefresh", ATTR_LAST_REFRESH));
|
||||
if (backupUser.backupTier().compareTo(BackupTier.MEDIA) >= 0) {
|
||||
// update media TTL
|
||||
expressionAttributeNames.put("#lastMediaRefresh", ATTR_LAST_MEDIA_REFRESH);
|
||||
updates.add("#lastMediaRefresh = :expiration");
|
||||
}
|
||||
return dynamoClient.updateItem(UpdateItemRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupUser))))
|
||||
.updateExpression("SET %s".formatted(String.join(",", updates)))
|
||||
.expressionAttributeNames(expressionAttributeNames)
|
||||
.expressionAttributeValues(Map.of(":expiration", AttributeValues.n(refreshTimeSecs)))
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
public record BackupInfo(int cdn, String backupSubdir, String messageBackupKey, Optional<Long> mediaUsedSpace) {}
|
||||
|
||||
/**
|
||||
* Retrieve information about the existing backup
|
||||
*
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
* @return Information about the existing backup
|
||||
*/
|
||||
public CompletableFuture<BackupInfo> backupInfo(final AuthenticatedBackupUser backupUser) {
|
||||
if (backupUser.backupTier().compareTo(BackupTier.MESSAGES) < 0) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
|
||||
throw Status.PERMISSION_DENIED.withDescription("credential does not support info operation")
|
||||
.asRuntimeException();
|
||||
}
|
||||
return backupInfoHelper(backupUser);
|
||||
}
|
||||
|
||||
private CompletableFuture<BackupInfo> backupInfoHelper(final AuthenticatedBackupUser backupUser) {
|
||||
return dynamoClient.getItem(GetItemRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupUser))))
|
||||
.projectionExpression("#cdn,#bytesUsed")
|
||||
.expressionAttributeNames(Map.of("#cdn", ATTR_CDN, "#bytesUsed", ATTR_MEDIA_BYTES_USED))
|
||||
.build())
|
||||
.thenApply(response -> {
|
||||
if (!response.hasItem()) {
|
||||
throw Status.NOT_FOUND.withDescription("Backup not found").asRuntimeException();
|
||||
}
|
||||
final int cdn = AttributeValues.get(response.item(), ATTR_CDN)
|
||||
.map(AttributeValue::n)
|
||||
.map(Integer::parseInt)
|
||||
.orElseThrow(() -> Status.NOT_FOUND.withDescription("Stored backup not found").asRuntimeException());
|
||||
|
||||
final Optional<Long> mediaUsed = AttributeValues.get(response.item(), ATTR_MEDIA_BYTES_USED)
|
||||
.map(AttributeValue::n)
|
||||
.map(Long::parseLong);
|
||||
|
||||
return new BackupInfo(cdn, encodeForCdn(hashedBackupId(backupUser)), MESSAGE_BACKUP_NAME, mediaUsed);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate credentials that can be used to read from the backup CDN
|
||||
*
|
||||
* @param backupUser an already ZK authenticated backup user
|
||||
* @return A map of headers to include with CDN requests
|
||||
*/
|
||||
public Map<String, String> generateReadAuth(final AuthenticatedBackupUser backupUser) {
|
||||
if (backupUser.backupTier().compareTo(BackupTier.MESSAGES) < 0) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME).increment();
|
||||
throw Status.PERMISSION_DENIED
|
||||
.withDescription("credential does not support read auth operation")
|
||||
.asRuntimeException();
|
||||
|
||||
}
|
||||
final String encodedBackupId = encodeForCdn(hashedBackupId(backupUser));
|
||||
return tusBackupCredentialGenerator.readHeaders(encodedBackupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the ZK anonymous backup credential's presentation
|
||||
* <p>
|
||||
* This validates:
|
||||
* <li> The presentation was for a credential issued by the server </li>
|
||||
* <li> The credential is in its redemption window </li>
|
||||
* <li> The backup-id matches a previously committed blinded backup-id and server issued receipt level </li>
|
||||
* <li> The signature of the credential matches an existing publicKey associated with this backup-id </li>
|
||||
*
|
||||
* @param presentation A {@link BackupAuthCredentialPresentation}
|
||||
* @param signature An XEd25519 signature of the presentation bytes
|
||||
* @return On authentication success, the authenticated backup-id and backup-tier encoded in the presentation
|
||||
*/
|
||||
public CompletableFuture<AuthenticatedBackupUser> authenticateBackupUser(
|
||||
final BackupAuthCredentialPresentation presentation,
|
||||
final byte[] signature) {
|
||||
final byte[] hashedBackupId = hashedBackupId(presentation.getBackupId());
|
||||
return dynamoClient.getItem(GetItemRequest.builder()
|
||||
.tableName(backupTableName)
|
||||
.key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))
|
||||
.projectionExpression("#publicKey")
|
||||
.expressionAttributeNames(Map.of("#publicKey", ATTR_PUBLIC_KEY))
|
||||
.build())
|
||||
.thenApply(response -> {
|
||||
if (!response.hasItem()) {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
FAILURE_REASON_TAG_NAME, "missing_public_key")
|
||||
.increment();
|
||||
throw Status.NOT_FOUND.withDescription("Backup not found").asRuntimeException();
|
||||
}
|
||||
final byte[] publicKeyBytes = AttributeValues.get(response.item(), ATTR_PUBLIC_KEY)
|
||||
.map(AttributeValue::b)
|
||||
.map(SdkBytes::asByteArray)
|
||||
.orElseThrow(() -> Status.INTERNAL
|
||||
.withDescription("Stored backup missing public key")
|
||||
.asRuntimeException());
|
||||
try {
|
||||
final ECPublicKey publicKey = new ECPublicKey(publicKeyBytes);
|
||||
return new AuthenticatedBackupUser(
|
||||
presentation.getBackupId(),
|
||||
verifySignatureAndCheckPresentation(presentation, signature, publicKey));
|
||||
} catch (InvalidKeyException e) {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
FAILURE_REASON_TAG_NAME, "invalid_public_key")
|
||||
.increment();
|
||||
logger.error("Invalid publicKey for backupId hash {}",
|
||||
HexFormat.of().formatHex(hashedBackupId), e);
|
||||
throw Status.INTERNAL
|
||||
.withCause(e)
|
||||
.withDescription("Could not deserialize stored public key")
|
||||
.asRuntimeException();
|
||||
}
|
||||
})
|
||||
.thenApply(result -> {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME, SUCCESS_TAG_NAME, String.valueOf(true)).increment();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Verify the presentation and return the extracted backup tier
|
||||
*
|
||||
* @param presentation A ZK credential presentation that encodes the backupId and the receipt level of the requester
|
||||
* @return The backup tier this presentation supports
|
||||
*/
|
||||
private BackupTier verifySignatureAndCheckPresentation(
|
||||
final BackupAuthCredentialPresentation presentation,
|
||||
final byte[] signature,
|
||||
final ECPublicKey publicKey) {
|
||||
if (!publicKey.verifySignature(presentation.serialize(), signature)) {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
FAILURE_REASON_TAG_NAME, "signature_validation")
|
||||
.increment();
|
||||
throw Status.UNAUTHENTICATED
|
||||
.withDescription("backup auth credential presentation signature verification failed")
|
||||
.asRuntimeException();
|
||||
}
|
||||
try {
|
||||
presentation.verify(clock.instant(), serverSecretParams);
|
||||
} catch (VerificationFailedException e) {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
FAILURE_REASON_TAG_NAME, "presentation_verification")
|
||||
.increment();
|
||||
throw Status.UNAUTHENTICATED
|
||||
.withDescription("backup auth credential presentation verification failed")
|
||||
.withCause(e)
|
||||
.asRuntimeException();
|
||||
}
|
||||
|
||||
return BackupTier
|
||||
.fromReceiptLevel(presentation.getReceiptLevel())
|
||||
.orElseThrow(() -> {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME,
|
||||
SUCCESS_TAG_NAME, String.valueOf(false),
|
||||
FAILURE_REASON_TAG_NAME, "invalid_receipt_level")
|
||||
.increment();
|
||||
return Status.PERMISSION_DENIED.withDescription("invalid receipt level").asRuntimeException();
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] hashedBackupId(final AuthenticatedBackupUser backupId) {
|
||||
return hashedBackupId(backupId.backupId());
|
||||
}
|
||||
|
||||
private static byte[] hashedBackupId(final byte[] backupId) {
|
||||
try {
|
||||
return Arrays.copyOf(MessageDigest.getInstance("SHA-256").digest(backupId), 16);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String encodeForCdn(final byte[] bytes) {
|
||||
return Base64.getUrlEncoder().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public enum BackupTier {
|
||||
NONE(0),
|
||||
MESSAGES(10),
|
||||
MEDIA(20);
|
||||
|
||||
private static Map<Long, BackupTier> LOOKUP = Arrays.stream(BackupTier.values())
|
||||
.collect(Collectors.toMap(BackupTier::getReceiptLevel, Function.identity()));
|
||||
private long receiptLevel;
|
||||
|
||||
private BackupTier(long receiptLevel) {
|
||||
this.receiptLevel = receiptLevel;
|
||||
}
|
||||
|
||||
long getReceiptLevel() {
|
||||
return receiptLevel;
|
||||
}
|
||||
|
||||
static Optional<BackupTier> fromReceiptLevel(long receiptLevel) {
|
||||
return Optional.ofNullable(LOOKUP.get(receiptLevel));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record MessageBackupUploadDescriptor(
|
||||
int cdn,
|
||||
String key,
|
||||
Map<String, String> headers,
|
||||
String signedUploadLocation) {}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.backup;
|
||||
|
||||
import org.apache.http.HttpHeaders;
|
||||
import org.whispersystems.textsecuregcm.attachments.TusConfiguration;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
public class TusBackupCredentialGenerator {
|
||||
|
||||
private static final int BACKUP_CDN = 3;
|
||||
|
||||
private static String READ_PERMISSION = "read";
|
||||
private static String WRITE_PERMISSION = "write";
|
||||
private static String CDN_PATH = "backups";
|
||||
private static String PERMISSION_SEPARATOR = "$";
|
||||
|
||||
// Write entities will be of the form 'write$backups/<string>
|
||||
private static final String WRITE_ENTITY_PREFIX = String.format("%s%s%s/", WRITE_PERMISSION, PERMISSION_SEPARATOR,
|
||||
CDN_PATH);
|
||||
// Read entities will be of the form 'read$backups/<string>
|
||||
private static final String READ_ENTITY_PREFIX = String.format("%s%s%s/", READ_PERMISSION, PERMISSION_SEPARATOR,
|
||||
CDN_PATH);
|
||||
|
||||
private final ExternalServiceCredentialsGenerator credentialsGenerator;
|
||||
private final String tusUri;
|
||||
|
||||
public TusBackupCredentialGenerator(final TusConfiguration cfg) {
|
||||
this.tusUri = cfg.uploadUri();
|
||||
this.credentialsGenerator = credentialsGenerator(Clock.systemUTC(), cfg);
|
||||
}
|
||||
|
||||
private static ExternalServiceCredentialsGenerator credentialsGenerator(final Clock clock,
|
||||
final TusConfiguration cfg) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.userAuthenticationTokenSharedSecret())
|
||||
.prependUsername(false)
|
||||
.withClock(clock)
|
||||
.build();
|
||||
}
|
||||
|
||||
public MessageBackupUploadDescriptor generateUpload(final String hashedBackupId, final String objectName) {
|
||||
if (hashedBackupId.isBlank() || objectName.isBlank()) {
|
||||
throw new IllegalArgumentException("Upload descriptors must have non-empty keys");
|
||||
}
|
||||
final String key = "%s/%s".formatted(hashedBackupId, objectName);
|
||||
final String entity = WRITE_ENTITY_PREFIX + key;
|
||||
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(entity);
|
||||
final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
|
||||
final Map<String, String> headers = Map.of(
|
||||
HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials),
|
||||
"Upload-Metadata", String.format("filename %s", b64Key));
|
||||
|
||||
return new MessageBackupUploadDescriptor(
|
||||
BACKUP_CDN,
|
||||
key,
|
||||
headers,
|
||||
tusUri + "/" + CDN_PATH);
|
||||
}
|
||||
|
||||
public Map<String, String> readHeaders(final String hashedBackupId) {
|
||||
if (hashedBackupId.isBlank()) {
|
||||
throw new IllegalArgumentException("Backup subdir name must be non-empty");
|
||||
}
|
||||
final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(
|
||||
READ_ENTITY_PREFIX + hashedBackupId);
|
||||
return Map.of(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user