Add ArchiveController
Adds endpoints for creating and managing backup objects with ZK anonymous credentials.
This commit is contained in:
parent
ba139dddd8
commit
6b38b538f1
2
pom.xml
2
pom.xml
|
@ -276,7 +276,7 @@
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.signal</groupId>
|
<groupId>org.signal</groupId>
|
||||||
<artifactId>libsignal-server</artifactId>
|
<artifactId>libsignal-server</artifactId>
|
||||||
<version>0.30.0</version>
|
<version>0.33.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.logging.log4j</groupId>
|
<groupId>org.apache.logging.log4j</groupId>
|
||||||
|
|
|
@ -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==
|
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==
|
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.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # base64-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users
|
||||||
paymentsService.fixerApiKey: unset
|
paymentsService.fixerApiKey: unset
|
||||||
|
|
|
@ -85,6 +85,8 @@ dynamoDbTables:
|
||||||
phoneNumberTableName: Example_Accounts_PhoneNumbers
|
phoneNumberTableName: Example_Accounts_PhoneNumbers
|
||||||
phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers
|
phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers
|
||||||
usernamesTableName: Example_Accounts_Usernames
|
usernamesTableName: Example_Accounts_Usernames
|
||||||
|
backups:
|
||||||
|
tableName: Example_Backups
|
||||||
clientReleases:
|
clientReleases:
|
||||||
tableName: Example_ClientReleases
|
tableName: Example_ClientReleases
|
||||||
deletedAccounts:
|
deletedAccounts:
|
||||||
|
@ -266,8 +268,11 @@ zkConfig:
|
||||||
serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||||
serverSecret: secret://zkConfig.serverSecret
|
serverSecret: secret://zkConfig.serverSecret
|
||||||
|
|
||||||
genericZkConfig:
|
callingZkConfig:
|
||||||
serverSecret: secret://genericZkConfig.serverSecret
|
serverSecret: secret://callingZkConfig.serverSecret
|
||||||
|
|
||||||
|
backupsZkConfig:
|
||||||
|
serverSecret: secret://backupsZkConfig.serverSecret
|
||||||
|
|
||||||
appConfig:
|
appConfig:
|
||||||
application: example
|
application: example
|
||||||
|
|
|
@ -224,7 +224,12 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
@Valid
|
@Valid
|
||||||
@NotNull
|
@NotNull
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private GenericZkConfig genericZkConfig;
|
private GenericZkConfig callingZkConfig;
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotNull
|
||||||
|
@JsonProperty
|
||||||
|
private GenericZkConfig backupsZkConfig;
|
||||||
|
|
||||||
@Valid
|
@Valid
|
||||||
@NotNull
|
@NotNull
|
||||||
|
@ -435,8 +440,12 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
return zkConfig;
|
return zkConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public GenericZkConfig getGenericZkConfig() {
|
public GenericZkConfig getCallingZkConfig() {
|
||||||
return genericZkConfig;
|
return callingZkConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GenericZkConfig getBackupsZkConfig() {
|
||||||
|
return backupsZkConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public RemoteConfigConfiguration getRemoteConfigConfiguration() {
|
public RemoteConfigConfiguration getRemoteConfigConfiguration() {
|
||||||
|
|
|
@ -76,6 +76,9 @@ import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager
|
||||||
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
|
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
|
||||||
import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener;
|
import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener;
|
||||||
import org.whispersystems.textsecuregcm.auth.grpc.BasicCredentialAuthenticationInterceptor;
|
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.ConfiguredProfileBadgeConverter;
|
||||||
import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator;
|
import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator;
|
||||||
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
|
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.AttachmentControllerV2;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
|
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4;
|
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4;
|
||||||
|
import org.whispersystems.textsecuregcm.controllers.ArchiveController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.CallLinkController;
|
import org.whispersystems.textsecuregcm.controllers.CallLinkController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.CertificateController;
|
import org.whispersystems.textsecuregcm.controllers.CertificateController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.ChallengeController;
|
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.limits.RateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
|
||||||
|
import org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
|
||||||
|
@ -638,11 +643,18 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
config.getCdnConfiguration().region());
|
config.getCdnConfiguration().region());
|
||||||
|
|
||||||
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().serverSecret().value());
|
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().serverSecret().value());
|
||||||
GenericServerSecretParams genericZkSecretParams = new GenericServerSecretParams(config.getGenericZkConfig().serverSecret().value());
|
GenericServerSecretParams callingGenericZkSecretParams = new GenericServerSecretParams(config.getCallingZkConfig().serverSecret().value());
|
||||||
|
GenericServerSecretParams backupsGenericZkSecretParams = new GenericServerSecretParams(config.getBackupsZkConfig().serverSecret().value());
|
||||||
ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams);
|
ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams);
|
||||||
ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams);
|
ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams);
|
||||||
ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams);
|
ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams);
|
||||||
|
|
||||||
|
TusBackupCredentialGenerator tusBackupCredentialGenerator = new TusBackupCredentialGenerator(config.getTus());
|
||||||
|
BackupAuthManager backupAuthManager = new BackupAuthManager(dynamicConfigurationManager, rateLimiters, accountsManager, backupsGenericZkSecretParams, clock);
|
||||||
|
BackupManager backupManager = new BackupManager(backupsGenericZkSecretParams, tusBackupCredentialGenerator, dynamoDbAsyncClient,
|
||||||
|
config.getDynamoDbTables().getBackups().getTableName(),
|
||||||
|
clock);
|
||||||
|
|
||||||
AuthFilter<BasicCredentials, AuthenticatedAccount> accountAuthFilter = new BasicCredentialAuthFilter.Builder<AuthenticatedAccount>().setAuthenticator(
|
AuthFilter<BasicCredentials, AuthenticatedAccount> accountAuthFilter = new BasicCredentialAuthFilter.Builder<AuthenticatedAccount>().setAuthenticator(
|
||||||
accountAuthenticator).buildAuthFilter();
|
accountAuthenticator).buildAuthFilter();
|
||||||
AuthFilter<BasicCredentials, DisabledPermittedAuthenticatedAccount> disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder<DisabledPermittedAuthenticatedAccount>().setAuthenticator(
|
AuthFilter<BasicCredentials, DisabledPermittedAuthenticatedAccount> disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder<DisabledPermittedAuthenticatedAccount>().setAuthenticator(
|
||||||
|
@ -767,8 +779,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().accessKey().value(), config.getAwsAttachmentsConfiguration().accessSecret().value(), config.getAwsAttachmentsConfiguration().region(), config.getAwsAttachmentsConfiguration().bucket()),
|
new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().accessKey().value(), config.getAwsAttachmentsConfiguration().accessSecret().value(), config.getAwsAttachmentsConfiguration().region(), config.getAwsAttachmentsConfiguration().bucket()),
|
||||||
new AttachmentControllerV3(rateLimiters, gcsAttachmentGenerator),
|
new AttachmentControllerV3(rateLimiters, gcsAttachmentGenerator),
|
||||||
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, new TusAttachmentGenerator(config.getTus()), experimentEnrollmentManager),
|
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, new TusAttachmentGenerator(config.getTus()), experimentEnrollmentManager),
|
||||||
new CallLinkController(rateLimiters, genericZkSecretParams),
|
new ArchiveController(backupAuthManager, backupManager),
|
||||||
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate().value(), config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()), zkAuthOperations, genericZkSecretParams, clock),
|
new CallLinkController(rateLimiters, callingGenericZkSecretParams),
|
||||||
|
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate().value(), config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()), zkAuthOperations, callingGenericZkSecretParams, clock),
|
||||||
new ChallengeController(rateLimitChallengeManager),
|
new ChallengeController(rateLimitChallengeManager),
|
||||||
new DeviceController(config.getLinkDeviceSecretConfiguration().secret().value(), accountsManager, messagesManager, keys, rateLimiters,
|
new DeviceController(config.getLinkDeviceSecretConfiguration().secret().value(), accountsManager, messagesManager, keys, rateLimiters,
|
||||||
rateLimitersCluster, config.getMaxDevices(), clock),
|
rateLimitersCluster, config.getMaxDevices(), clock),
|
||||||
|
@ -869,6 +882,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
List.of(
|
List.of(
|
||||||
new LoggingUnhandledExceptionMapper(),
|
new LoggingUnhandledExceptionMapper(),
|
||||||
new CompletionExceptionMapper(),
|
new CompletionExceptionMapper(),
|
||||||
|
new GrpcStatusRuntimeExceptionMapper(),
|
||||||
new IOExceptionMapper(),
|
new IOExceptionMapper(),
|
||||||
new RateLimitExceededExceptionMapper(),
|
new RateLimitExceededExceptionMapper(),
|
||||||
new InvalidWebsocketAddressExceptionMapper(),
|
new InvalidWebsocketAddressExceptionMapper(),
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.auth;
|
||||||
|
|
||||||
|
import org.whispersystems.textsecuregcm.backup.BackupTier;
|
||||||
|
|
||||||
|
public record AuthenticatedBackupUser(byte[] backupId, BackupTier backupTier) {}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -47,6 +47,8 @@ public class DynamoDbTables {
|
||||||
}
|
}
|
||||||
|
|
||||||
private final AccountsTableConfiguration accounts;
|
private final AccountsTableConfiguration accounts;
|
||||||
|
|
||||||
|
private final Table backups;
|
||||||
private final Table clientReleases;
|
private final Table clientReleases;
|
||||||
private final Table deletedAccounts;
|
private final Table deletedAccounts;
|
||||||
private final Table deletedAccountsLock;
|
private final Table deletedAccountsLock;
|
||||||
|
@ -68,6 +70,7 @@ public class DynamoDbTables {
|
||||||
|
|
||||||
public DynamoDbTables(
|
public DynamoDbTables(
|
||||||
@JsonProperty("accounts") final AccountsTableConfiguration accounts,
|
@JsonProperty("accounts") final AccountsTableConfiguration accounts,
|
||||||
|
@JsonProperty("backups") final Table backups,
|
||||||
@JsonProperty("clientReleases") final Table clientReleases,
|
@JsonProperty("clientReleases") final Table clientReleases,
|
||||||
@JsonProperty("deletedAccounts") final Table deletedAccounts,
|
@JsonProperty("deletedAccounts") final Table deletedAccounts,
|
||||||
@JsonProperty("deletedAccountsLock") final Table deletedAccountsLock,
|
@JsonProperty("deletedAccountsLock") final Table deletedAccountsLock,
|
||||||
|
@ -88,6 +91,7 @@ public class DynamoDbTables {
|
||||||
@JsonProperty("verificationSessions") final Table verificationSessions) {
|
@JsonProperty("verificationSessions") final Table verificationSessions) {
|
||||||
|
|
||||||
this.accounts = accounts;
|
this.accounts = accounts;
|
||||||
|
this.backups = backups;
|
||||||
this.clientReleases = clientReleases;
|
this.clientReleases = clientReleases;
|
||||||
this.deletedAccounts = deletedAccounts;
|
this.deletedAccounts = deletedAccounts;
|
||||||
this.deletedAccountsLock = deletedAccountsLock;
|
this.deletedAccountsLock = deletedAccountsLock;
|
||||||
|
@ -114,6 +118,12 @@ public class DynamoDbTables {
|
||||||
return accounts;
|
return accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Valid
|
||||||
|
public Table getBackups() {
|
||||||
|
return backups;
|
||||||
|
}
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Valid
|
@Valid
|
||||||
public Table getClientReleases() {
|
public Table getClientReleases() {
|
||||||
|
|
|
@ -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<Void> 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<BackupAuthCredential> 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<BackupAuthCredentialsResponse> 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
|
||||||
|
* <li> 400 if regular auth is used by accident </li>
|
||||||
|
* <li> 401 if the anonymous auth invalid </li>
|
||||||
|
* <li> 403 if the anonymous credential does not have sufficient permissions </li>
|
||||||
|
*/
|
||||||
|
@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<String, String> 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<ReadAuthResponse> readAuth(
|
||||||
|
@Auth final Optional<AuthenticatedAccount> 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<BackupInfoResponse> backupInfo(
|
||||||
|
@Auth final Optional<AuthenticatedAccount> 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<Void> setPublicKey(
|
||||||
|
@Auth final Optional<AuthenticatedAccount> 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<String, String> 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<MessageBackupResponse> backup(
|
||||||
|
@Auth final Optional<AuthenticatedAccount> 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<Void> refresh(
|
||||||
|
@Auth final Optional<AuthenticatedAccount> 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -46,6 +46,7 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
|
||||||
RATE_LIMIT_RESET("rateLimitReset", true, new RateLimiterConfig(2, Duration.ofHours(12))),
|
RATE_LIMIT_RESET("rateLimitReset", true, new RateLimiterConfig(2, Duration.ofHours(12))),
|
||||||
RECAPTCHA_CHALLENGE_ATTEMPT("recaptchaChallengeAttempt", true, new RateLimiterConfig(10, Duration.ofMinutes(144))),
|
RECAPTCHA_CHALLENGE_ATTEMPT("recaptchaChallengeAttempt", true, new RateLimiterConfig(10, Duration.ofMinutes(144))),
|
||||||
RECAPTCHA_CHALLENGE_SUCCESS("recaptchaChallengeSuccess", true, new RateLimiterConfig(2, Duration.ofHours(12))),
|
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_ATTEMPT("pushChallengeAttempt", true, new RateLimiterConfig(10, Duration.ofMinutes(144))),
|
||||||
PUSH_CHALLENGE_SUCCESS("pushChallengeSuccess", true, new RateLimiterConfig(2, Duration.ofHours(12))),
|
PUSH_CHALLENGE_SUCCESS("pushChallengeSuccess", true, new RateLimiterConfig(2, Duration.ofHours(12))),
|
||||||
CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, Duration.ofMinutes(15))),
|
CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, Duration.ofMinutes(15))),
|
||||||
|
|
|
@ -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<StatusRuntimeException> {
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -100,6 +100,9 @@ public class Account {
|
||||||
@JsonProperty("inCds")
|
@JsonProperty("inCds")
|
||||||
private boolean discoverableByPhoneNumber = true;
|
private boolean discoverableByPhoneNumber = true;
|
||||||
|
|
||||||
|
@JsonProperty("bcr")
|
||||||
|
private byte[] backupCredentialRequest;
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private int version;
|
private int version;
|
||||||
|
|
||||||
|
@ -486,6 +489,14 @@ public class Account {
|
||||||
this.version = version;
|
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?
|
* Have all this account's devices been manually locked?
|
||||||
|
|
|
@ -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<T> extends JsonDeserializer<T> {
|
||||||
|
|
||||||
|
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<T extends ByteArray> extends JsonSerializer<T> {
|
||||||
|
|
||||||
|
@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<BackupAuthCredentialRequest> {}
|
||||||
|
public static class CredentialRequestDeserializer extends GenericDeserializer<BackupAuthCredentialRequest> {
|
||||||
|
@Override
|
||||||
|
BackupAuthCredentialRequest deserialize(final byte[] bytes) throws InvalidInputException {
|
||||||
|
return new BackupAuthCredentialRequest(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,7 +5,9 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.storage;
|
package org.whispersystems.textsecuregcm.storage;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
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.AttributeDefinition;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
|
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
|
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
|
||||||
|
@ -47,6 +49,14 @@ public final class DynamoDbExtensionSchema {
|
||||||
),
|
),
|
||||||
List.of()),
|
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",
|
CLIENT_RELEASES("client_releases_test",
|
||||||
ClientReleases.ATTR_PLATFORM,
|
ClientReleases.ATTR_PLATFORM,
|
||||||
ClientReleases.ATTR_VERSION,
|
ClientReleases.ATTR_VERSION,
|
||||||
|
@ -84,7 +94,7 @@ public final class DynamoDbExtensionSchema {
|
||||||
.build()),
|
.build()),
|
||||||
List.of()
|
List.of()
|
||||||
),
|
),
|
||||||
|
|
||||||
DELETED_ACCOUNTS_LOCK("deleted_accounts_lock_test",
|
DELETED_ACCOUNTS_LOCK("deleted_accounts_lock_test",
|
||||||
AccountLockManager.KEY_ACCOUNT_E164,
|
AccountLockManager.KEY_ACCOUNT_E164,
|
||||||
null,
|
null,
|
||||||
|
@ -92,7 +102,7 @@ public final class DynamoDbExtensionSchema {
|
||||||
.attributeName(AccountLockManager.KEY_ACCOUNT_E164)
|
.attributeName(AccountLockManager.KEY_ACCOUNT_E164)
|
||||||
.attributeType(ScalarAttributeType.S).build()),
|
.attributeType(ScalarAttributeType.S).build()),
|
||||||
List.of(), List.of()),
|
List.of(), List.of()),
|
||||||
|
|
||||||
NUMBERS("numbers_test",
|
NUMBERS("numbers_test",
|
||||||
Accounts.ATTR_ACCOUNT_E164,
|
Accounts.ATTR_ACCOUNT_E164,
|
||||||
null,
|
null,
|
||||||
|
@ -233,7 +243,7 @@ public final class DynamoDbExtensionSchema {
|
||||||
.attributeType(ScalarAttributeType.S)
|
.attributeType(ScalarAttributeType.S)
|
||||||
.build()),
|
.build()),
|
||||||
List.of(), List.of()),
|
List.of(), List.of()),
|
||||||
|
|
||||||
PUSH_CHALLENGES("push_challenge_test",
|
PUSH_CHALLENGES("push_challenge_test",
|
||||||
PushChallengeDynamoDb.KEY_ACCOUNT_UUID,
|
PushChallengeDynamoDb.KEY_ACCOUNT_UUID,
|
||||||
null,
|
null,
|
||||||
|
@ -251,7 +261,7 @@ public final class DynamoDbExtensionSchema {
|
||||||
.attributeType(ScalarAttributeType.B)
|
.attributeType(ScalarAttributeType.B)
|
||||||
.build()),
|
.build()),
|
||||||
List.of(), List.of()),
|
List.of(), List.of()),
|
||||||
|
|
||||||
REGISTRATION_RECOVERY_PASSWORDS("registration_recovery_passwords_test",
|
REGISTRATION_RECOVERY_PASSWORDS("registration_recovery_passwords_test",
|
||||||
RegistrationRecoveryPasswords.KEY_E164,
|
RegistrationRecoveryPasswords.KEY_E164,
|
||||||
null,
|
null,
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue