Add ArchiveController

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

View File

@@ -0,0 +1,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<BackupAuthManager.Credential> 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<Arguments> 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;
}
}

View File

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

View File

@@ -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 <T> ThrowableAssert.ThrowingCallable unwrapExceptions(final Supplier<CompletableFuture<T>> f) {
return () -> {
try {
f.get().join();
} catch (Exception e) {
if (ExceptionUtils.unwrap(e) instanceof StatusRuntimeException ex) {
throw ex;
}
throw e;
}
};
}
}

View File

@@ -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<String, String> 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];
}
}

View File

@@ -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<Arguments> 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<BackupAuthManager.Credential> 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();
}
}

View File

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

View File

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

View File

@@ -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<DynamicConfiguration> withEnrollment(
final String experimentName,
final Set<UUID> enrolledUuids,
final int enrollmentPercentage) {
final DynamicConfigurationManager<DynamicConfiguration> 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<DynamicConfiguration> withEnrollment(final String experimentName, final Set<UUID> enrolledUuids) {
return withEnrollment(experimentName, enrolledUuids, 0);
}
public static DynamicConfigurationManager<DynamicConfiguration> withEnrollment(final String experimentName, final UUID enrolledUuid) {
return withEnrollment(experimentName, Set.of(enrolledUuid), 0);
}
}