Compare commits

...

14 Commits

Author SHA1 Message Date
Ravi Khadiwala ae2d98750c Add SecureValueRecoveryBController 2025-07-11 13:39:18 -05:00
Ravi Khadiwala 7d41c1219b Add /v2/svr as an alternative name for /v2/backup 2025-07-11 13:39:18 -05:00
Ravi Khadiwala 65e1f1b3a9 Arm the RemoveOrphanedPreKeyPagesCommand 2025-07-11 13:26:15 -05:00
Ameya Lokare 437b823c84 Update to the latest version of the spam filter 2025-07-09 13:27:04 -07:00
ravi-signal c9f21d5970
Always read from new and old PQ prekey stores, add experiment to start writing to new prekey store 2025-07-09 09:17:17 -05:00
Ravi Khadiwala 80c11e7eda Handle 429s from play API and add subscription docs 2025-07-09 09:15:29 -05:00
Jon Chambers 0745cabc87 Explicitly use synchronous flush mode when clearing Redis databases between tests 2025-07-09 09:15:15 -05:00
Jon Chambers 3e80669f4e Reuse/extend lifetime of Redis singleton resources 2025-07-09 09:15:15 -05:00
Jon Chambers b81cd9ec61 Reuse Redis clusters for the duration of a test run 2025-07-09 09:15:15 -05:00
Jon Chambers da6ed94443 Reuse client resources for lifetime of Redis cluster 2025-07-09 09:15:15 -05:00
Ameya Lokare 96d41b3716 Update to the latest version of the spam filter 2025-07-07 09:16:48 -07:00
Ravi Khadiwala 7dddc4d759 fix an incorrect backup metric 2025-07-07 18:14:53 +02:00
Katherine a87690d817
Include Redis cluster and shard address in circuit breaker log 2025-07-07 12:12:44 -04:00
Ameya Lokare 18ef3da261 Update dependencies 2025-06-30 14:17:03 -07:00
43 changed files with 974 additions and 532 deletions

38
pom.xml
View File

@ -37,10 +37,10 @@
</modules>
<properties>
<aws.sdk2.version>2.31.9</aws.sdk2.version>
<braintree.version>3.40.0</braintree.version>
<aws.sdk2.version>2.31.70</aws.sdk2.version>
<braintree.version>3.42.0</braintree.version>
<commons-csv.version>1.14.0</commons-csv.version>
<commons-io.version>2.18.0</commons-io.version>
<commons-io.version>2.19.0</commons-io.version>
<dropwizard.version>4.0.12</dropwizard.version>
<dropwizard-metrics-datadog.version>1.1.14</dropwizard-metrics-datadog.version>
<!-- can be updated to latest version with Dropwizard 5 (Jetty 12); will then need to disable telemetry -->
@ -52,39 +52,39 @@
<foundationdb.version>7.3.62</foundationdb.version>
<foundationdb.api-version>730</foundationdb.api-version>
<foundationdb.client-library-sha256>bfed237b787fae3cde1222676e6bfbb0d218fc27bf9e903397a7a7aa96fb2d33</foundationdb.client-library-sha256>
<google-cloud-libraries.version>26.57.0</google-cloud-libraries.version>
<grpc.version>1.70.0</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->
<gson.version>2.12.1</gson.version>
<google-cloud-libraries.version>26.62.0</google-cloud-libraries.version>
<grpc.version>1.73.0</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->
<gson.version>2.13.1</gson.version>
<!-- several libraries (AWS, Google Cloud) use Apache http components transitively, and we need to align them -->
<httpcore.version>4.4.16</httpcore.version>
<httpclient.version>4.5.14</httpclient.version>
<jackson.version>2.18.3</jackson.version>
<jackson.version>2.19.1</jackson.version>
<junit-pioneer.version>2.3.0</junit-pioneer.version>
<jsr305.version>3.0.2</jsr305.version>
<kotlin.version>2.1.20</kotlin.version>
<kotlin.version>2.2.0</kotlin.version>
<!-- Logback 1.5.14+ has a null pointer bug: https://github.com/qos-ch/logback/issues/929. -->
<logback.version>1.5.13</logback.version>
<logback-access.version>2.0.5</logback-access.version>
<lettuce.version>6.5.5.RELEASE</lettuce.version>
<libphonenumber.version>9.0.2</libphonenumber.version>
<logstash.logback.version>7.3</logstash.logback.version>
<log4j-bom.version>2.24.3</log4j-bom.version>
<logback-access-common.version>2.0.5</logback-access-common.version>
<lettuce.version>6.7.1.RELEASE</lettuce.version>
<libphonenumber.version>9.0.8</libphonenumber.version>
<logstash.logback.version>8.1</logstash.logback.version>
<log4j-bom.version>2.25.0</log4j-bom.version>
<luajava.version>3.5.0</luajava.version>
<micrometer.version>1.14.5</micrometer.version>
<netty.version>4.1.119.Final</netty.version>
<micrometer.version>1.15.1</micrometer.version>
<netty.version>4.1.122.Final</netty.version>
<!-- Must be less than or equal to the value from Google libraries-bom which controls the protobuf runtime version.
See https://protobuf.dev/support/cross-version-runtime-guarantee/. -->
<protoc.version>4.29.4</protoc.version>
<pushy.version>0.15.4</pushy.version>
<reactive.grpc.version>1.2.4</reactive.grpc.version>
<reactor-bom.version>2024.0.4</reactor-bom.version> <!-- 3.7.4, see https://github.com/reactor/reactor#bom-versioning-scheme -->
<reactor-bom.version>2024.0.7</reactor-bom.version> <!-- 3.7.4, see https://github.com/reactor/reactor#bom-versioning-scheme -->
<resilience4j.version>2.3.0</resilience4j.version>
<semver4j.version>3.1.0</semver4j.version>
<simple-grpc.version>0.1.0</simple-grpc.version>
<slf4j.version>2.0.17</slf4j.version>
<stripe.version>23.10.0</stripe.version>
<swagger.version>2.2.27</swagger.version>
<testcontainers.version>1.21.1</testcontainers.version>
<swagger.version>2.2.31</swagger.version>
<testcontainers.version>1.21.2</testcontainers.version>
<!-- image to use in tests that run localstack via docker. -->
<localstack.image>localstack/localstack:3.5.0</localstack.image>
@ -325,7 +325,7 @@
<dependency>
<groupId>ch.qos.logback.access</groupId>
<artifactId>logback-access-common</artifactId>
<version>${logback-access.version}</version>
<version>${logback-access-common.version}</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>

View File

@ -16,6 +16,9 @@ directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789
svr2.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users
svr2.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users
svrb.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth tokens for Signal users
svrb.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth identity tokens for Signal users
tus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG=
gcpAttachments.rsaSigningKey: |

View File

@ -225,6 +225,34 @@ svr2:
AAAAAAAAAAAAAAAAAAAA
-----END CERTIFICATE-----
svrb:
uri: svrb.example.com
userAuthenticationTokenSharedSecret: secret://svrb.userAuthenticationTokenSharedSecret
userIdTokenSharedSecret: secret://svrb.userIdTokenSharedSecret
svrCaCertificates:
- |
-----BEGIN CERTIFICATE-----
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
AAAAAAAAAAAAAAAAAAAA
-----END CERTIFICATE-----
messageCache: # Redis server configuration for message store cache
persistDelayMinutes: 1
cluster:

View File

@ -51,6 +51,13 @@
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-jaxrs2-jakarta</artifactId>
<version>${swagger.version}</version>
<exclusions>
<!-- conflicts with jackson-dataformat-yaml -->
<exclusion>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>

View File

@ -52,7 +52,7 @@ import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration;
import org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
import org.whispersystems.textsecuregcm.configuration.ShortCodeExpanderConfiguration;
import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
@ -156,7 +156,12 @@ public class WhisperServerConfiguration extends Configuration {
@NotNull
@Valid
@JsonProperty
private SecureValueRecovery2Configuration svr2;
private SecureValueRecoveryConfiguration svr2;
@NotNull
@Valid
@JsonProperty
private SecureValueRecoveryConfiguration svrb;
@NotNull
@Valid
@ -389,10 +394,14 @@ public class WhisperServerConfiguration extends Configuration {
return pubsub;
}
public SecureValueRecovery2Configuration getSvr2Configuration() {
public SecureValueRecoveryConfiguration getSvr2Configuration() {
return svr2;
}
public SecureValueRecoveryConfiguration getSvrbConfiguration() {
return svrb;
}
public DirectoryV2Configuration getDirectoryV2Configuration() {
return directoryV2;
}

View File

@ -124,6 +124,7 @@ import org.whispersystems.textsecuregcm.controllers.RegistrationController;
import org.whispersystems.textsecuregcm.controllers.RemoteConfigController;
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller;
import org.whispersystems.textsecuregcm.controllers.SecureValueRecoveryBController;
import org.whispersystems.textsecuregcm.controllers.StickerController;
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
import org.whispersystems.textsecuregcm.controllers.VerificationController;
@ -279,6 +280,7 @@ import org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand;
import org.whispersystems.textsecuregcm.workers.RemoveExpiredBackupsCommand;
import org.whispersystems.textsecuregcm.workers.RemoveExpiredLinkedDevicesCommand;
import org.whispersystems.textsecuregcm.workers.RemoveExpiredUsernameHoldsCommand;
import org.whispersystems.textsecuregcm.workers.RemoveOrphanedPreKeyPagesCommand;
import org.whispersystems.textsecuregcm.workers.ScheduledApnPushNotificationSenderServiceCommand;
import org.whispersystems.textsecuregcm.workers.ServerVersionCommand;
import org.whispersystems.textsecuregcm.workers.SetRequestLoggingEnabledTask;
@ -332,6 +334,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
bootstrap.addCommand(new RemoveExpiredAccountsCommand(Clock.systemUTC()));
bootstrap.addCommand(new RemoveExpiredUsernameHoldsCommand(Clock.systemUTC()));
bootstrap.addCommand(new RemoveExpiredBackupsCommand(Clock.systemUTC()));
bootstrap.addCommand(new RemoveOrphanedPreKeyPagesCommand(Clock.systemUTC()));
bootstrap.addCommand(new BackupMetricsCommand(Clock.systemUTC()));
bootstrap.addCommand(new BackupUsageRecalculationCommand());
bootstrap.addCommand(new RemoveExpiredLinkedDevicesCommand());
@ -368,6 +371,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
MetricsUtil.configureRegistries(config, environment, dynamicConfigurationManager);
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);
if (config.getServerFactory() instanceof DefaultServerFactory defaultServerFactory) {
defaultServerFactory.getApplicationConnectors()
.forEach(connectorFactory -> {
@ -444,7 +449,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getDynamoDbTables().getPagedKemKeys().getTableName(),
config.getPagedSingleUseKEMPreKeyStore().bucket()),
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getEcSignedPreKeys().getTableName()),
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getKemLastResortKeys().getTableName()));
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getKemLastResortKeys().getTableName()),
experimentEnrollmentManager);
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
config.getDynamoDbTables().getMessages().getTableName(),
config.getDynamoDbTables().getMessages().getExpiration(),
@ -603,9 +609,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getPaymentsServiceConfiguration());
ExternalServiceCredentialsGenerator svr2CredentialsGenerator = SecureValueRecovery2Controller.credentialsGenerator(
config.getSvr2Configuration());
ExternalServiceCredentialsGenerator svrbCredentialsGenerator = SecureValueRecoveryBController.credentialsGenerator(
config.getSvrbConfiguration());
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(
dynamicConfigurationManager);
RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords);
UsernameHashZkProofVerifier usernameHashZkProofVerifier = new UsernameHashZkProofVerifier();
@ -1118,6 +1124,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig(), clock),
new SecureStorageController(storageCredentialsGenerator),
new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager),
new SecureValueRecoveryBController(svrbCredentialsGenerator),
new StickerController(rateLimiters, config.getCdnConfiguration().credentials().accessKeyId().value(),
config.getCdnConfiguration().credentials().secretAccessKey().value(), config.getCdnConfiguration().region(),
config.getCdnConfiguration().bucket()),

View File

@ -297,7 +297,7 @@ public class BackupsDb {
.tags(tags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
.record(mediaCount);
.record(bytesUsed);
// Report that the backup is out of quota if it cannot store a max size media object
final boolean quotaExhausted = bytesUsed >=

View File

@ -12,7 +12,7 @@ import java.util.List;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
import org.whispersystems.textsecuregcm.util.ExactlySize;
public record SecureValueRecovery2Configuration(
public record SecureValueRecoveryConfiguration(
@NotBlank String uri,
@ExactlySize(32) SecretBytes userAuthenticationTokenSharedSecret,
@ExactlySize(32) SecretBytes userIdTokenSharedSecret,
@ -20,7 +20,7 @@ public record SecureValueRecovery2Configuration(
@NotNull @Valid CircuitBreakerConfiguration circuitBreaker,
@NotNull @Valid RetryConfiguration retry) {
public SecureValueRecovery2Configuration {
public SecureValueRecoveryConfiguration {
if (circuitBreaker == null) {
circuitBreaker = new CircuitBreakerConfiguration();
}

View File

@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers;
import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.auth.Auth;
import io.swagger.v3.oas.annotations.Operation;
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 jakarta.validation.Valid;
@ -27,7 +28,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
@ -35,18 +36,19 @@ import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
@Path("/v2/backup")
@Path("/v2/{name: backup|svr}")
@Tag(name = "Secure Value Recovery")
@Schema(description = "Note: /v2/backup is deprecated. Use /v2/svr instead.")
public class SecureValueRecovery2Controller {
private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg) {
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecoveryConfiguration cfg) {
return credentialsGenerator(cfg, Clock.systemUTC());
}
@VisibleForTesting
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg, final Clock clock) {
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecoveryConfiguration cfg, final Clock clock) {
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.withUserDerivationKey(cfg.userIdTokenSharedSecret().value())

View File

@ -0,0 +1,63 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.auth.Auth;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Clock;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
@Path("/v1/svrb")
@Tag(name = "Secure Value Recovery B")
public class SecureValueRecoveryBController {
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecoveryConfiguration cfg) {
return credentialsGenerator(cfg, Clock.systemUTC());
}
@VisibleForTesting
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecoveryConfiguration cfg,
final Clock clock) {
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.withUserDerivationKey(cfg.userIdTokenSharedSecret().value())
.prependUsername(false)
.withDerivedUsernameTruncateLength(16)
.withClock(clock)
.build();
}
private final ExternalServiceCredentialsGenerator svrbCredentialGenerator;
public SecureValueRecoveryBController(final ExternalServiceCredentialsGenerator svrbCredentialGenerator) {
this.svrbCredentialGenerator = svrbCredentialGenerator;
}
@GET
@Path("/auth")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Generate credentials for SVRB",
description = """
Generate SVRB service credentials. Generated credentials have an expiration time of 1 day (subject to change)
"""
)
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
public ExternalServiceCredentials getAuth(@Auth final AuthenticatedDevice auth) {
return svrbCredentialGenerator.generateFor(auth.accountIdentifier().toString());
}
}

View File

@ -15,6 +15,7 @@ import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.swagger.v3.oas.annotations.ExternalDocumentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@ -66,6 +67,7 @@ import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
@ -218,6 +220,19 @@ public class SubscriptionController {
@DELETE
@Path("/{subscriberId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Cancel a subscription", description = """
Cancels any current subscription at the end of the current subscription period.
Note: Apple IAP subscriptions do not support server-side cancellation, so this method should only be called after
cancelling a subscription from storekit to keep server data up to date.
""")
@ApiResponse(responseCode = "200", description = "All subscriptions cancelled")
@ApiResponse(responseCode = "403", description = "Account authentication is present")
@ApiResponse(responseCode = "404", description = "subscriberId is not found or malformed")
@ApiResponse(responseCode = "400", description = "The associated subscription is not a type that can be cancelled")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<Response> deleteSubscriber(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
@ -230,6 +245,16 @@ public class SubscriptionController {
@Path("/{subscriberId}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create/refresh a subscriber", description = """
Creates a subscriber record if it does not exist, otherwise refreshes its last access time.
Subscribers MUST periodically hit this endpoint to update the access time on the subscription record. Subscribers
SHOULD attempt to make an update call approximately every 3 days. Not accessing this endpoint for an extended
period of time will result in the subscription being canceled.
""")
@ApiResponse(responseCode = "200", description = "The subscriber was successfully created or refreshed")
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "subscriberId is malformed")
public CompletableFuture<Response> updateSubscriber(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
@ -429,7 +454,9 @@ public class SubscriptionController {
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the specified transaction does not exist")
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support appstore payments. Delete this subscriberId and use a new one.")
@ApiResponse(responseCode = "429", description = "Rate limit exceeded.")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setAppStoreSubscription(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@ -471,6 +498,9 @@ public class SubscriptionController {
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist")
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support Play Billing. Delete this subscriberId and use a new one.")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setPlayStoreSubscription(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@ -625,6 +655,9 @@ public class SubscriptionController {
@ApiResponse(responseCode = "200", description = "The subscriberId exists", content = @Content(schema = @Schema(implementation = GetSubscriptionInformationResponse.class)))
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<Response> getSubscriptionInformation(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
@ -650,16 +683,64 @@ public class SubscriptionController {
.orElseGet(() -> Response.ok(new GetSubscriptionInformationResponse(null, null)).build()));
}
public record GetReceiptCredentialsRequest(@NotEmpty byte[] receiptCredentialRequest) {
public record GetReceiptCredentialsRequest(
@Schema(description = "A ReceiptCredentialRequest encoded in standard base64 with padding")
@NotEmpty byte[] receiptCredentialRequest) {
}
public record GetReceiptCredentialsResponse(@NotEmpty byte[] receiptCredentialResponse) {
public record GetReceiptCredentialsResponse(
@Schema(description = "A ReceiptCredentialResponse encoded in standard base64 with padding")
@NotEmpty byte[] receiptCredentialResponse) {
}
@POST
@Path("/{subscriberId}/receipt_credentials")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create receipt credentials", description = """
Create a receipt from a valid payment invoice that can be used to obtain an entitlement
This request is repeatable so long as the ReceiptCredentialRequest remains the same. Clients should use the same
ReceiptCredentialRequest value until they attempt to redeem the resulting ReceiptCredentialPresentation. After
this point, the ReceiptCredentialRequest MUST NOT be reused or you may not be able to redeem a valid payment
invoice. Clients SHOULD retry requests at this endpoint with the same ReceiptCredentialRequest value until
receiving a response. After receiving a response, clients should then compute the ReceiptCredentialPresentation
and redeem it at the receipt redemption endpoint. Once the first attempt is made there, the same
ReceiptCredentialRequest MUST NOT be used again to request receipt credentials.
Note that you may in fact redeem TWO or more invoices for the same ReceiptCredentialRequest while retrying this
operation if a later invoice gets paid while you are retrying. However, the returned receipt is always for the
latest invoice, so it will have the latest expiration possible and no entitlement time will be lost. The important
thing is not to reuse ReceiptCredentialRequest after you have started attempting to redeem the associated
ReceiptCredentialPresentation. Then you may produce a ReceiptCredentialPresentation for a later invoice that
cannot be redeemed.
Clients MUST validate that the generated receipt credential's level and expiration matches their expectations.
""")
@ApiResponse(responseCode = "200", description = "Successfully created receipt", content = @Content(schema = @Schema(implementation = GetReceiptCredentialsResponse.class)))
@ApiResponse(responseCode = "204", description = "No invoice has been issued for this subscription OR invoice is in 'open' state")
@ApiResponse(responseCode = "400", description = "Bad ReceiptCredentialRequest")
@ApiResponse(responseCode = "402", description = "Invoice is in any state other than 'open' or 'paid'. May include chargeFailure details in body.",
content = @Content(schema = @Schema(
nullable = true,
example = """
{
"chargeFailure": {
"code": "incorrect_account_holder_name",
"message": "The transaction can't be processed because your customer's account information is missing [...]",
"outcomeNetworkStatus": "declined_by_network",
"outcomeReason": "generic_decline",
"outcomeType": "issuer_declined"
}
}
""",
implementation = SubscriptionExceptionMapper.ChargeFailureResponse.class)))
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "subscriberId is not found OR malformed OR no subscription setup on the subscriber id")
@ApiResponse(responseCode = "409", description = "latest paid receipt on subscription was already redeemed for a receipt credential but with a different receipt credential request")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public CompletableFuture<Response> createSubscriptionReceiptCredentials(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,

View File

@ -1,64 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Map;
import javax.annotation.Nullable;
import jakarta.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
public record AuthCheckResponseV3(
@Schema(description = """
A dictionary with the auth check results, keyed by the token corresponding token provided in the request.
""")
@NotNull Map<String, Result> matches) {
public record Result(
@Schema(description = "The status of the credential. Either match, no-match, or invalid")
CredentialStatus status,
@Schema(description = """
If the credential was a match, the stored shareSet that can be used to restore a value from SVR. Encoded in
standard un-padded base64.
""", implementation = String.class)
@JsonSerialize(using = ByteArrayAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
@Nullable byte[] shareSet) {
public static Result invalid() {
return new Result(CredentialStatus.INVALID, null);
}
public static Result noMatch() {
return new Result(CredentialStatus.NO_MATCH, null);
}
public static Result match(@Nullable final byte[] shareSet) {
return new Result(CredentialStatus.MATCH, shareSet);
}
}
public enum CredentialStatus {
MATCH("match"),
NO_MATCH("no-match"),
INVALID("invalid");
private final String clientCode;
CredentialStatus(final String clientCode) {
this.clientCode = clientCode;
}
@JsonValue
public String clientCode() {
return clientCode;
}
}
}

View File

@ -18,7 +18,7 @@ import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration;
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
enum ExternalServiceDefinitions {
DIRECTORY(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, (chatConfig, clock) -> {
@ -38,7 +38,17 @@ enum ExternalServiceDefinitions {
.build();
}),
SVR(ExternalServiceType.EXTERNAL_SERVICE_TYPE_SVR, (chatConfig, clock) -> {
final SecureValueRecovery2Configuration cfg = chatConfig.getSvr2Configuration();
final SecureValueRecoveryConfiguration cfg = chatConfig.getSvr2Configuration();
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.withUserDerivationKey(cfg.userIdTokenSharedSecret().value())
.prependUsername(false)
.withDerivedUsernameTruncateLength(16)
.withClock(clock)
.build();
}),
SVRB(ExternalServiceType.EXTERNAL_SERVICE_TYPE_SVRB, (chatConfig, clock) -> {
final SecureValueRecoveryConfiguration cfg = chatConfig.getSvrbConfiguration();
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.withUserDerivationKey(cfg.userIdTokenSharedSecret().value())

View File

@ -13,11 +13,14 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import java.util.Map;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
public class SubscriptionExceptionMapper implements ExceptionMapper<SubscriptionException> {
@VisibleForTesting
public static final int PROCESSOR_ERROR_STATUS_CODE = 440;
public record ChargeFailureResponse(String processor, ChargeFailure chargeFailure) {}
@Override
public Response toResponse(final SubscriptionException exception) {
@ -31,17 +34,14 @@ public class SubscriptionExceptionMapper implements ExceptionMapper<Subscription
}
if (exception instanceof SubscriptionException.ProcessorException e) {
return Response.status(PROCESSOR_ERROR_STATUS_CODE)
.entity(Map.of(
"processor", e.getProcessor().name(),
"chargeFailure", e.getChargeFailure()
))
.entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}
if (exception instanceof SubscriptionException.ChargeFailurePaymentRequired e) {
return Response
.status(Response.Status.PAYMENT_REQUIRED)
.entity(Map.of("chargeFailure", e.getChargeFailure()))
.entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))
.type(MediaType.APPLICATION_JSON_TYPE)
.build();
}

View File

@ -248,7 +248,7 @@ public class LettuceShardCircuitBreaker implements NettyCustomizer {
// RedisNoScriptException doesnt indicate a fault the breaker can protect
if (throwable != null && !(throwable instanceof RedisNoScriptException)) {
breaker.onError(durationNanos, TimeUnit.NANOSECONDS, throwable);
logger.warn("Command completed with error", throwable);
logger.warn("Command completed with error for: {}/{}", clusterName, shardAddress, throwable);
} else {
breaker.onSuccess(durationNanos, TimeUnit.NANOSECONDS);
}

View File

@ -21,7 +21,7 @@ import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
import org.whispersystems.textsecuregcm.util.HttpUtils;
@ -39,7 +39,7 @@ public class SecureValueRecovery2Client {
public SecureValueRecovery2Client(final ExternalServiceCredentialsGenerator secureValueRecoveryCredentialsGenerator,
final Executor executor, final ScheduledExecutorService retryExecutor,
final SecureValueRecovery2Configuration configuration)
final SecureValueRecoveryConfiguration configuration)
throws CertificateException {
this.secureValueRecoveryCredentialsGenerator = secureValueRecoveryCredentialsGenerator;
this.deleteUri = URI.create(configuration.uri()).resolve(DELETE_PATH);

View File

@ -5,7 +5,7 @@
package org.whispersystems.textsecuregcm.storage;
import java.time.Instant;
import io.micrometer.core.instrument.Metrics;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ -13,6 +13,8 @@ import java.util.concurrent.CompletableFuture;
import org.whispersystems.textsecuregcm.entities.ECPreKey;
import org.whispersystems.textsecuregcm.entities.ECSignedPreKey;
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import reactor.core.publisher.Flux;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
@ -23,18 +25,25 @@ public class KeysManager {
private final PagedSingleUseKEMPreKeyStore pagedPqPreKeys;
private final RepeatedUseECSignedPreKeyStore ecSignedPreKeys;
private final RepeatedUseKEMSignedPreKeyStore pqLastResortKeys;
private final ExperimentEnrollmentManager experimentEnrollmentManager;
public static String PAGED_KEYS_EXPERIMENT_NAME = "pagedPreKeys";
private static final String TAKE_PQ_NAME = MetricsUtil.name(KeysManager.class, "takePq");
public KeysManager(
final SingleUseECPreKeyStore ecPreKeys,
final SingleUseKEMPreKeyStore pqPreKeys,
final PagedSingleUseKEMPreKeyStore pagedPqPreKeys,
final RepeatedUseECSignedPreKeyStore ecSignedPreKeys,
final RepeatedUseKEMSignedPreKeyStore pqLastResortKeys) {
final RepeatedUseKEMSignedPreKeyStore pqLastResortKeys,
final ExperimentEnrollmentManager experimentEnrollmentManager) {
this.ecPreKeys = ecPreKeys;
this.pqPreKeys = pqPreKeys;
this.pagedPqPreKeys = pagedPqPreKeys;
this.ecSignedPreKeys = ecSignedPreKeys;
this.pqLastResortKeys = pqLastResortKeys;
this.experimentEnrollmentManager = experimentEnrollmentManager;
}
public TransactWriteItem buildWriteItemForEcSignedPreKey(final UUID identifier,
@ -79,22 +88,31 @@ public class KeysManager {
);
}
public CompletableFuture<Void> storeEcSignedPreKeys(final UUID identifier, final byte deviceId, final ECSignedPreKey ecSignedPreKey) {
public CompletableFuture<Void> storeEcSignedPreKeys(final UUID identifier, final byte deviceId,
final ECSignedPreKey ecSignedPreKey) {
return ecSignedPreKeys.store(identifier, deviceId, ecSignedPreKey);
}
public CompletableFuture<Void> storePqLastResort(final UUID identifier, final byte deviceId, final KEMSignedPreKey lastResortKey) {
public CompletableFuture<Void> storePqLastResort(final UUID identifier, final byte deviceId,
final KEMSignedPreKey lastResortKey) {
return pqLastResortKeys.store(identifier, deviceId, lastResortKey);
}
public CompletableFuture<Void> storeEcOneTimePreKeys(final UUID identifier, final byte deviceId,
final List<ECPreKey> preKeys) {
final List<ECPreKey> preKeys) {
return ecPreKeys.store(identifier, deviceId, preKeys);
}
public CompletableFuture<Void> storeKemOneTimePreKeys(final UUID identifier, final byte deviceId,
final List<KEMSignedPreKey> preKeys) {
return pqPreKeys.store(identifier, deviceId, preKeys);
final List<KEMSignedPreKey> preKeys) {
final boolean enrolledInPagedKeys = experimentEnrollmentManager.isEnrolled(identifier, PAGED_KEYS_EXPERIMENT_NAME);
final CompletableFuture<Void> deleteOtherKeys = enrolledInPagedKeys
? pqPreKeys.delete(identifier, deviceId)
: pagedPqPreKeys.delete(identifier, deviceId);
return deleteOtherKeys.thenCompose(ignored -> enrolledInPagedKeys
? pagedPqPreKeys.store(identifier, deviceId, preKeys)
: pqPreKeys.store(identifier, deviceId, preKeys));
}
public CompletableFuture<Optional<ECPreKey>> takeEC(final UUID identifier, final byte deviceId) {
@ -102,10 +120,36 @@ public class KeysManager {
}
public CompletableFuture<Optional<KEMSignedPreKey>> takePQ(final UUID identifier, final byte deviceId) {
return pqPreKeys.take(identifier, deviceId)
final boolean enrolledInPagedKeys = experimentEnrollmentManager.isEnrolled(identifier, PAGED_KEYS_EXPERIMENT_NAME);
return tagTakePQ(pagedPqPreKeys.take(identifier, deviceId), PQSource.PAGE, enrolledInPagedKeys)
.thenCompose(maybeSingleUsePreKey -> maybeSingleUsePreKey
.map(ignored -> CompletableFuture.completedFuture(maybeSingleUsePreKey))
.orElseGet(() -> tagTakePQ(pqPreKeys.take(identifier, deviceId), PQSource.ROW, enrolledInPagedKeys)))
.thenCompose(maybeSingleUsePreKey -> maybeSingleUsePreKey
.map(singleUsePreKey -> CompletableFuture.completedFuture(maybeSingleUsePreKey))
.orElseGet(() -> pqLastResortKeys.find(identifier, deviceId)));
.orElseGet(() -> tagTakePQ(pqLastResortKeys.find(identifier, deviceId), PQSource.LAST_RESORT, enrolledInPagedKeys)));
}
private enum PQSource {
PAGE,
ROW,
LAST_RESORT
}
private CompletableFuture<Optional<KEMSignedPreKey>> tagTakePQ(CompletableFuture<Optional<KEMSignedPreKey>> prekey, final PQSource source, final boolean enrolledInPagedKeys) {
return prekey.thenApply(maybeSingleUsePreKey -> {
final Optional<String> maybeSourceTag = maybeSingleUsePreKey
// If we found a PK, use this source tag
.map(ignore -> source.name())
// If we didn't and this is our last resort, we didn't find a PK
.or(() -> source == PQSource.LAST_RESORT ? Optional.of("absent") : Optional.empty());
maybeSourceTag.ifPresent(sourceTag -> {
Metrics.counter(TAKE_PQ_NAME,
"source", sourceTag,
"enrolled", Boolean.toString(enrolledInPagedKeys))
.increment();
});
return maybeSingleUsePreKey;
});
}
public CompletableFuture<Optional<KEMSignedPreKey>> getLastResort(final UUID identifier, final byte deviceId) {
@ -121,20 +165,24 @@ public class KeysManager {
}
public CompletableFuture<Integer> getPqCount(final UUID identifier, final byte deviceId) {
return pqPreKeys.getCount(identifier, deviceId);
return pagedPqPreKeys.getCount(identifier, deviceId).thenCompose(count -> count == 0
? pqPreKeys.getCount(identifier, deviceId)
: CompletableFuture.completedFuture(count));
}
public CompletableFuture<Void> deleteSingleUsePreKeys(final UUID identifier) {
return CompletableFuture.allOf(
ecPreKeys.delete(identifier),
pqPreKeys.delete(identifier)
pqPreKeys.delete(identifier),
pagedPqPreKeys.delete(identifier)
);
}
public CompletableFuture<Void> deleteSingleUsePreKeys(final UUID accountUuid, final byte deviceId) {
return CompletableFuture.allOf(
ecPreKeys.delete(accountUuid, deviceId),
pqPreKeys.delete(accountUuid, deviceId)
pqPreKeys.delete(accountUuid, deviceId),
pagedPqPreKeys.delete(accountUuid, deviceId)
);
}

View File

@ -22,7 +22,6 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -44,7 +43,12 @@ import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.*;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Object;
/**
* @implNote This version of a {@link SingleUsePreKeyStore} store bundles prekeys into "pages", which are stored in on

View File

@ -102,16 +102,23 @@ public class SubscriptionException extends Exception {
public static class ChargeFailurePaymentRequired extends SubscriptionException {
private final PaymentProvider processor;
private final ChargeFailure chargeFailure;
public ChargeFailurePaymentRequired(final ChargeFailure chargeFailure) {
public ChargeFailurePaymentRequired(final PaymentProvider processor, final ChargeFailure chargeFailure) {
super(null, null);
this.processor = processor;
this.chargeFailure = chargeFailure;
}
public PaymentProvider getProcessor() {
return processor;
}
public ChargeFailure getChargeFailure() {
return chargeFailure;
}
}
public static class ProcessorException extends SubscriptionException {

View File

@ -633,7 +633,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
if (subscriptionStatus.equals(SubscriptionStatus.ACTIVE) || subscriptionStatus.equals(SubscriptionStatus.PAST_DUE)) {
throw ExceptionUtils.wrap(new SubscriptionException.ReceiptRequestedForOpenPayment());
}
throw ExceptionUtils.wrap(new SubscriptionException.ChargeFailurePaymentRequired(createChargeFailure(transaction)));
throw ExceptionUtils.wrap(new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(transaction)));
}
final Instant paidAt = transaction.getSubscriptionDetails().getBillingPeriodStartDate().toInstant();

View File

@ -43,6 +43,7 @@ import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
@ -362,10 +363,14 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
|| e.getStatusCode() == Response.Status.GONE.getStatusCode()) {
throw ExceptionUtils.wrap(new SubscriptionException.NotFound());
}
if (e.getStatusCode() == Response.Status.TOO_MANY_REQUESTS.getStatusCode()) {
throw ExceptionUtils.wrap(new RateLimitExceededException(null));
}
final String details = e instanceof GoogleJsonResponseException
? ((GoogleJsonResponseException) e).getDetails().toString()
: "";
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), details, e);
logger.warn("Unexpected HTTP status code {} from androidpublisher: {}", e.getStatusCode(), details);
throw ExceptionUtils.wrap(e);
}));
}

View File

@ -645,7 +645,8 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
// If the charge object has a failure reason we can present to the user, create a detailed exception
.filter(charge -> charge.getFailureCode() != null || charge.getFailureMessage() != null)
.<SubscriptionException> map(charge -> new SubscriptionException.ChargeFailurePaymentRequired(createChargeFailure(charge)))
.<SubscriptionException> map(charge ->
new SubscriptionException.ChargeFailurePaymentRequired(getProvider(), createChargeFailure(charge)))
// Otherwise, return a generic payment required error
.orElseGet(() -> new SubscriptionException.PaymentRequired())));

View File

@ -33,6 +33,7 @@ import org.whispersystems.textsecuregcm.backup.Cdn3RemoteStorageManager;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.MicrometerAwsSdkMetricPublisher;
@ -122,6 +123,9 @@ record CommandDependencies(
new DynamicConfigurationManager<>(
configuration.getDynamicConfig().build(awsCredentialsProvider, dynamicConfigurationExecutor), DynamicConfiguration.class);
dynamicConfigurationManager.start();
ExperimentEnrollmentManager experimentEnrollmentManager =
new ExperimentEnrollmentManager(dynamicConfigurationManager);
final ClientResources.Builder redisClientResourcesBuilder = ClientResources.builder();
FaultTolerantRedisClusterClient cacheCluster = configuration.getCacheClusterConfiguration()
@ -224,7 +228,8 @@ record CommandDependencies(
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,
configuration.getDynamoDbTables().getEcSignedPreKeys().getTableName()),
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,
configuration.getDynamoDbTables().getKemLastResortKeys().getTableName()));
configuration.getDynamoDbTables().getKemLastResortKeys().getTableName()),
experimentEnrollmentManager);
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getMessages().getTableName(),
configuration.getDynamoDbTables().getMessages().getExpiration(),

View File

@ -53,6 +53,7 @@ enum ExternalServiceType {
EXTERNAL_SERVICE_TYPE_PAYMENTS = 2;
EXTERNAL_SERVICE_TYPE_STORAGE = 3;
EXTERNAL_SERVICE_TYPE_SVR = 4;
EXTERNAL_SERVICE_TYPE_SVRB = 5;
}
message GetExternalServiceCredentialsRequest {

View File

@ -33,9 +33,8 @@ public class LocalFaultTolerantRedisClientFactory implements FaultTolerantRedisC
if (shutdownHookConfigured.compareAndSet(false, true)) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
REDIS_SERVER_EXTENSION.afterEach(null);
REDIS_SERVER_EXTENSION.afterAll(null);
} catch (Exception e) {
REDIS_SERVER_EXTENSION.close();
} catch (Throwable e) {
throw new RuntimeException(e);
}
}));

View File

@ -34,8 +34,8 @@ public class LocalFaultTolerantRedisClusterFactory implements FaultTolerantRedis
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
redisClusterExtension.afterEach(null);
redisClusterExtension.afterAll(null);
} catch (Exception e) {
redisClusterExtension.close();
} catch (Throwable e) {
throw new RuntimeException(e);
}
}));

View File

@ -6,28 +6,43 @@
package org.whispersystems.textsecuregcm.controllers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
import java.util.stream.Collectors;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@ExtendWith(DropwizardExtensionsSupport.class)
public class SecureValueRecovery2ControllerTest extends SecureValueRecoveryControllerBaseTest {
public class SecureValueRecovery2ControllerTest {
private static final SecureValueRecovery2Configuration CFG = new SecureValueRecovery2Configuration(
private static final SecureValueRecoveryConfiguration CFG = new SecureValueRecoveryConfiguration(
"",
randomSecretBytes(32),
randomSecretBytes(32),
@ -52,19 +67,305 @@ public class SecureValueRecovery2ControllerTest extends SecureValueRecoveryContr
.addResource(CONTROLLER)
.build();
protected SecureValueRecovery2ControllerTest() {
super("/v2", ACCOUNTS_MANAGER, CLOCK, RESOURCES, CREDENTIAL_GENERATOR);
@Nested
class WithBackupsPrefix extends SecureValueRecoveryControllerBaseTest {
protected WithBackupsPrefix() {
super("/v2/backup");
}
}
@Override
Map<String, CheckStatus> parseCheckResponse(final Response response) {
final AuthCheckResponseV2 authCheckResponseV2 = response.readEntity(AuthCheckResponseV2.class);
return authCheckResponseV2.matches().entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey, e -> switch (e.getValue()) {
case MATCH -> CheckStatus.MATCH;
case INVALID -> CheckStatus.INVALID;
case NO_MATCH -> CheckStatus.NO_MATCH;
}
));
@Nested
class WithSvr2Prefix extends SecureValueRecoveryControllerBaseTest {
protected WithSvr2Prefix() {
super("/v2/svr");
}
}
static abstract class SecureValueRecoveryControllerBaseTest {
private static final UUID USER_1 = UUID.randomUUID();
private static final UUID USER_2 = UUID.randomUUID();
private static final UUID USER_3 = UUID.randomUUID();
private static final String E164_VALID = "+18005550123";
private static final String E164_INVALID = "1(800)555-0123";
private final String pathPrefix;
@BeforeEach
public void before() throws Exception {
Mockito.reset(ACCOUNTS_MANAGER);
Mockito.when(ACCOUNTS_MANAGER.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1)));
}
protected SecureValueRecoveryControllerBaseTest(final String pathPrefix) {
this.pathPrefix = pathPrefix;
}
enum CheckStatus {
MATCH,
NO_MATCH,
INVALID
}
private Map<String, CheckStatus> parseCheckResponse(final Response response) {
final AuthCheckResponseV2 authCheckResponseV2 = response.readEntity(AuthCheckResponseV2.class);
return authCheckResponseV2.matches().entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey, e -> switch (e.getValue()) {
case MATCH -> CheckStatus.MATCH;
case INVALID -> CheckStatus.INVALID;
case NO_MATCH -> CheckStatus.NO_MATCH;
}
));
}
@Test
public void testOneMatch() {
validate(Map.of(
token(USER_1, day(1)), CheckStatus.MATCH,
token(USER_2, day(1)), CheckStatus.NO_MATCH,
token(USER_3, day(1)), CheckStatus.NO_MATCH
), day(2));
}
@Test
public void testNoMatch() {
validate(Map.of(
token(USER_2, day(1)), CheckStatus.NO_MATCH,
token(USER_3, day(1)), CheckStatus.NO_MATCH
), day(2));
}
@Test
public void testSomeInvalid() {
final ExternalServiceCredentials user1Cred = credentials(USER_1, day(1));
final ExternalServiceCredentials user2Cred = credentials(USER_2, day(1));
final ExternalServiceCredentials user3Cred = credentials(USER_3, day(1));
final String fakeToken = token(new ExternalServiceCredentials(user2Cred.username(), user3Cred.password()));
validate(Map.of(
token(user1Cred), CheckStatus.MATCH,
token(user2Cred), CheckStatus.NO_MATCH,
fakeToken, CheckStatus.INVALID
), day(2));
}
@Test
public void testSomeExpired() {
validate(Map.of(
token(USER_1, day(100)), CheckStatus.MATCH,
token(USER_2, day(100)), CheckStatus.NO_MATCH,
token(USER_3, day(10)), CheckStatus.INVALID,
token(USER_3, day(20)), CheckStatus.INVALID
), day(110));
}
@Test
public void testSomeHaveNewerVersions() {
validate(Map.of(
token(USER_1, day(10)), CheckStatus.INVALID,
token(USER_1, day(20)), CheckStatus.MATCH,
token(USER_2, day(10)), CheckStatus.NO_MATCH,
token(USER_3, day(20)), CheckStatus.NO_MATCH,
token(USER_3, day(10)), CheckStatus.INVALID
), day(25));
}
private void validate(
final Map<String, CheckStatus> expected,
final long nowMillis) {
CLOCK.setTimeMillis(nowMillis);
final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
final Response response = RESOURCES.getJerseyTest().target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity(request, MediaType.APPLICATION_JSON));
try (response) {
assertEquals(200, response.getStatus());
final Map<String, CheckStatus> res = parseCheckResponse(response);
assertEquals(expected, res);
}
}
@Test
public void testHttpResponseCodeSuccess() {
final Map<String, CheckStatus> expected = Map.of(
token(USER_1, day(10)), CheckStatus.INVALID,
token(USER_1, day(20)), CheckStatus.MATCH,
token(USER_2, day(10)), CheckStatus.NO_MATCH,
token(USER_3, day(20)), CheckStatus.NO_MATCH,
token(USER_3, day(10)), CheckStatus.INVALID
);
CLOCK.setTimeMillis(day(25));
final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
final Response response = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
assertEquals(200, response.getStatus());
assertEquals(expected, parseCheckResponse(response));
}
}
@Test
public void testHttpResponseCodeWhenInvalidNumber() {
final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList("1"));
final Response response = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenTooManyTokens() {
final AuthCheckRequest inOkay = new AuthCheckRequest(E164_VALID, List.of(
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
));
final AuthCheckRequest inTooMany = new AuthCheckRequest(E164_VALID, List.of(
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"
));
final AuthCheckRequest inNoTokens = new AuthCheckRequest(E164_VALID, Collections.emptyList());
final Response responseOkay = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity(inOkay, MediaType.APPLICATION_JSON));
final Response responseError1 = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON));
final Response responseError2 = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity(inNoTokens, MediaType.APPLICATION_JSON));
try (responseOkay; responseError1; responseError2) {
assertEquals(200, responseOkay.getStatus());
assertEquals(422, responseError1.getStatus());
assertEquals(422, responseError2.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenPasswordsMissing() {
final Response response = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity("""
{
"number": "123"
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenNumberMissing() {
final Response response = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity("""
{
"passwords": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenExtraFields() {
final Response response = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"passwords": ["aaa:bbb"],
"unexpected": "value"
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(200, response.getStatus());
}
}
@Test
public void testAcceptsPasswordsOrTokens() {
final Response passwordsResponse = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"passwords": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (passwordsResponse) {
assertEquals(200, passwordsResponse.getStatus());
}
final Response tokensResponse = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"tokens": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (tokensResponse) {
assertEquals(200, tokensResponse.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenNotAJson() {
final Response response = RESOURCES.getJerseyTest()
.target(pathPrefix + "/auth/check")
.request()
.post(Entity.entity("random text", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(400, response.getStatus());
}
}
private String token(final UUID uuid, final long timeMillis) {
return token(credentials(uuid, timeMillis));
}
private static String token(final ExternalServiceCredentials credentials) {
return credentials.username() + ":" + credentials.password();
}
private ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) {
CLOCK.setTimeMillis(timeMillis);
return CREDENTIAL_GENERATOR.generateForUuid(uuid);
}
private static long day(final int n) {
return TimeUnit.DAYS.toMillis(n);
}
private static Account account(final UUID uuid) {
final Account a = new Account();
a.setUuid(uuid);
return a;
}
}
}

View File

@ -0,0 +1,70 @@
/*
* 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.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import java.time.Instant;
import java.util.HexFormat;
@ExtendWith(DropwizardExtensionsSupport.class)
public class SecureValueRecoveryBControllerTest {
private static final SecureValueRecoveryConfiguration CFG = new SecureValueRecoveryConfiguration(
"",
randomSecretBytes(32),
randomSecretBytes(32),
null,
null,
null
);
private static final MutableClock CLOCK = new MutableClock();
private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR =
SecureValueRecoveryBController.credentialsGenerator(CFG, CLOCK);
private static final SecureValueRecoveryBController CONTROLLER =
new SecureValueRecoveryBController(CREDENTIAL_GENERATOR);
private static final ResourceExtension RESOURCES = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter())
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(CONTROLLER)
.build();
@Test
public void testGetCredentials() {
CLOCK.setTimeInstant(Instant.ofEpochSecond(123));
final ExternalServiceCredentials creds = RESOURCES.getJerseyTest()
.target("/v1/svrb/auth")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(ExternalServiceCredentials.class);
assertThat(HexFormat.of().parseHex(creds.username())).hasSize(16);
System.out.println(creds.password());
final String[] split = creds.password().split(":", 2);
assertThat(Long.parseLong(split[0])).isEqualTo(123);
}
}

View File

@ -1,324 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import io.dropwizard.testing.junit5.ResourceExtension;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
abstract class SecureValueRecoveryControllerBaseTest {
private static final UUID USER_1 = UUID.randomUUID();
private static final UUID USER_2 = UUID.randomUUID();
private static final UUID USER_3 = UUID.randomUUID();
private static final String E164_VALID = "+18005550123";
private static final String E164_INVALID = "1(800)555-0123";
private final String pathPrefix;
private final ResourceExtension resourceExtension;
private final AccountsManager mockAccountsManager;
private final ExternalServiceCredentialsGenerator credentialsGenerator;
private final MutableClock clock;
@BeforeEach
public void before() throws Exception {
Mockito.when(mockAccountsManager.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1)));
}
protected SecureValueRecoveryControllerBaseTest(
final String pathPrefix,
final AccountsManager mockAccountsManager,
final MutableClock mutableClock,
final ResourceExtension resourceExtension,
final ExternalServiceCredentialsGenerator credentialsGenerator) {
this.pathPrefix = pathPrefix;
this.resourceExtension = resourceExtension;
this.mockAccountsManager = mockAccountsManager;
this.credentialsGenerator = credentialsGenerator;
this.clock = mutableClock;
}
enum CheckStatus {
MATCH,
NO_MATCH,
INVALID
}
abstract Map<String, CheckStatus> parseCheckResponse(Response response);
@Test
public void testOneMatch() throws Exception {
validate(Map.of(
token(USER_1, day(1)), CheckStatus.MATCH,
token(USER_2, day(1)), CheckStatus.NO_MATCH,
token(USER_3, day(1)), CheckStatus.NO_MATCH
), day(2));
}
@Test
public void testNoMatch() throws Exception {
validate(Map.of(
token(USER_2, day(1)), CheckStatus.NO_MATCH,
token(USER_3, day(1)), CheckStatus.NO_MATCH
), day(2));
}
@Test
public void testSomeInvalid() throws Exception {
final ExternalServiceCredentials user1Cred = credentials(USER_1, day(1));
final ExternalServiceCredentials user2Cred = credentials(USER_2, day(1));
final ExternalServiceCredentials user3Cred = credentials(USER_3, day(1));
final String fakeToken = token(new ExternalServiceCredentials(user2Cred.username(), user3Cred.password()));
validate(Map.of(
token(user1Cred), CheckStatus.MATCH,
token(user2Cred), CheckStatus.NO_MATCH,
fakeToken, CheckStatus.INVALID
), day(2));
}
@Test
public void testSomeExpired() throws Exception {
validate(Map.of(
token(USER_1, day(100)), CheckStatus.MATCH,
token(USER_2, day(100)), CheckStatus.NO_MATCH,
token(USER_3, day(10)), CheckStatus.INVALID,
token(USER_3, day(20)), CheckStatus.INVALID
), day(110));
}
@Test
public void testSomeHaveNewerVersions() throws Exception {
validate(Map.of(
token(USER_1, day(10)), CheckStatus.INVALID,
token(USER_1, day(20)), CheckStatus.MATCH,
token(USER_2, day(10)), CheckStatus.NO_MATCH,
token(USER_3, day(20)), CheckStatus.NO_MATCH,
token(USER_3, day(10)), CheckStatus.INVALID
), day(25));
}
private void validate(
final Map<String, CheckStatus> expected,
final long nowMillis) throws Exception {
clock.setTimeMillis(nowMillis);
final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
final Response response = resourceExtension.getJerseyTest().target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(request, MediaType.APPLICATION_JSON));
try (response) {
assertEquals(200, response.getStatus());
final Map<String, CheckStatus> res = parseCheckResponse(response);
assertEquals(expected, res);
}
}
@Test
public void testHttpResponseCodeSuccess() throws Exception {
final Map<String, CheckStatus> expected = Map.of(
token(USER_1, day(10)), CheckStatus.INVALID,
token(USER_1, day(20)), CheckStatus.MATCH,
token(USER_2, day(10)), CheckStatus.NO_MATCH,
token(USER_3, day(20)), CheckStatus.NO_MATCH,
token(USER_3, day(10)), CheckStatus.INVALID
);
clock.setTimeMillis(day(25));
final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
assertEquals(200, response.getStatus());
assertEquals(expected, parseCheckResponse(response));
}
}
@Test
public void testHttpResponseCodeWhenInvalidNumber() throws Exception {
final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList("1"));
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenTooManyTokens() throws Exception {
final AuthCheckRequest inOkay = new AuthCheckRequest(E164_VALID, List.of(
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
));
final AuthCheckRequest inTooMany = new AuthCheckRequest(E164_VALID, List.of(
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"
));
final AuthCheckRequest inNoTokens = new AuthCheckRequest(E164_VALID, Collections.emptyList());
final Response responseOkay = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(inOkay, MediaType.APPLICATION_JSON));
final Response responseError1 = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON));
final Response responseError2 = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity(inNoTokens, MediaType.APPLICATION_JSON));
try (responseOkay; responseError1; responseError2) {
assertEquals(200, responseOkay.getStatus());
assertEquals(422, responseError1.getStatus());
assertEquals(422, responseError2.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenPasswordsMissing() throws Exception {
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "123"
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenNumberMissing() throws Exception {
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"passwords": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(422, response.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenExtraFields() throws Exception {
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"passwords": ["aaa:bbb"],
"unexpected": "value"
}
""", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(200, response.getStatus());
}
}
@Test
public void testAcceptsPasswordsOrTokens() {
final Response passwordsResponse = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"passwords": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (passwordsResponse) {
assertEquals(200, passwordsResponse.getStatus());
}
final Response tokensResponse = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("""
{
"number": "+18005550123",
"tokens": ["aaa:bbb"]
}
""", MediaType.APPLICATION_JSON));
try (tokensResponse) {
assertEquals(200, tokensResponse.getStatus());
}
}
@Test
public void testHttpResponseCodeWhenNotAJson() throws Exception {
final Response response = resourceExtension.getJerseyTest()
.target(pathPrefix + "/backup/auth/check")
.request()
.post(Entity.entity("random text", MediaType.APPLICATION_JSON));
try (response) {
assertEquals(400, response.getStatus());
}
}
String token(final UUID uuid, final long timeMillis) {
return token(credentials(uuid, timeMillis));
}
static String token(final ExternalServiceCredentials credentials) {
return credentials.username() + ":" + credentials.password();
}
private ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) {
clock.setTimeMillis(timeMillis);
return credentialsGenerator.generateForUuid(uuid);
}
static long day(final int n) {
return TimeUnit.DAYS.toMillis(n);
}
private static Account account(final UUID uuid) {
final Account a = new Account();
a.setUuid(uuid);
return a;
}
}

View File

@ -992,6 +992,44 @@ class SubscriptionControllerTest {
verify(PLAY_MANAGER, times(1)).cancelAllActiveSubscriptions(oldPurchaseToken);
}
@Test
void createReceiptChargeFailure() throws InvalidInputException, VerificationFailedException {
final byte[] subscriberUserAndKey = new byte[32];
Arrays.fill(subscriberUserAndKey, (byte) 1);
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
when(CLOCK.instant()).thenReturn(Instant.now());
when(SUBSCRIPTIONS.get(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(Subscriptions.Record.from(
Arrays.copyOfRange(subscriberUserAndKey, 0, 16),
Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),
Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),
Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),
Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID,
b(new ProcessorCustomer("customer", PaymentProvider.STRIPE).toDynamoBytes()),
Subscriptions.KEY_SUBSCRIPTION_ID, s("subscriptionId"))))));
when(STRIPE_MANAGER.getReceiptItem(any()))
.thenReturn(CompletableFuture.failedFuture(new SubscriptionException.ChargeFailurePaymentRequired(
PaymentProvider.STRIPE,
new ChargeFailure("card_declined", "Insufficient funds", null, null, null))));
final ReceiptCredentialRequest receiptRequest = new ClientZkReceiptOperations(
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();
final Response response = RESOURCE_EXTENSION
.target(String.format("/v1/subscription/%s/receipt_credentials", subscriberId))
.request()
.post(Entity.json(new SubscriptionController.GetReceiptCredentialsRequest(receiptRequest.serialize())));
assertThat(response.getStatus()).isEqualTo(402);
final Map responseMap = response.readEntity(Map.class);
assertThat(responseMap.get("processor")).isEqualTo("STRIPE");
assertThat(responseMap.get("chargeFailure")).asInstanceOf(
InstanceOfAssertFactories.map(String.class, Object.class))
.extracting("code")
.isEqualTo("card_declined");
}
@ParameterizedTest
@CsvSource({"5, P45D", "201, P13D"})
public void createReceiptCredential(long level, Duration expectedExpirationWindow)

View File

@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.redis;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.lettuce.core.FlushMode;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisException;
import io.lettuce.core.RedisURI;
@ -21,7 +22,6 @@ import java.net.ServerSocket;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
@ -32,18 +32,18 @@ import org.whispersystems.textsecuregcm.util.RedisClusterUtil;
import redis.embedded.RedisServer;
import redis.embedded.exceptions.EmbeddedRedisException;
public class RedisClusterExtension implements BeforeAllCallback, BeforeEachCallback, AfterAllCallback,
AfterEachCallback {
public class RedisClusterExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback,
ExtensionContext.Store.CloseableResource {
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(2);
private static final int NODE_COUNT = 2;
private static final RedisServer[] CLUSTER_NODES = new RedisServer[NODE_COUNT];
private static ClientResources redisClientResources;
private final Duration timeout;
private final RetryConfiguration retryConfiguration;
private FaultTolerantRedisClusterClient redisCluster;
private ClientResources redisClientResources;
public RedisClusterExtension(final Duration timeout, final RetryConfiguration retryConfiguration) {
this.timeout = timeout;
@ -56,35 +56,42 @@ public class RedisClusterExtension implements BeforeAllCallback, BeforeEachCallb
}
@Override
public void afterAll(final ExtensionContext context) throws Exception {
for (final RedisServer node : CLUSTER_NODES) {
node.stop();
public void close() throws Throwable {
if (redisClientResources != null) {
redisClientResources.shutdown().get();
for (final RedisServer node : CLUSTER_NODES) {
node.stop();
}
}
redisClientResources = null;
}
@Override
public void afterEach(final ExtensionContext context) throws Exception {
redisCluster.shutdown();
redisClientResources.shutdown().get();
}
@Override
public void beforeAll(final ExtensionContext context) throws Exception {
assumeFalse(System.getProperty("os.name").equalsIgnoreCase("windows"));
for (int i = 0; i < NODE_COUNT; i++) {
// We're occasionally seeing redis server startup failing due to the bind address being already in use.
// To mitigate that, we're going to just retry a couple of times before failing the test.
CLUSTER_NODES[i] = startWithRetries(3);
}
if (redisClientResources == null) {
redisClientResources = ClientResources.builder().build();
assembleCluster(CLUSTER_NODES);
for (int i = 0; i < NODE_COUNT; i++) {
// We're occasionally seeing redis server startup failing due to the bind address being already in use.
// To mitigate that, we're going to just retry a couple of times before failing the test.
CLUSTER_NODES[i] = startWithRetries(3);
}
assembleCluster(CLUSTER_NODES);
}
}
@Override
public void beforeEach(final ExtensionContext context) throws Exception {
redisClientResources = ClientResources.builder().build();
final CircuitBreakerConfiguration circuitBreakerConfig = new CircuitBreakerConfiguration();
circuitBreakerConfig.setWaitDurationInOpenState(Duration.ofMillis(500));
redisCluster = new FaultTolerantRedisClusterClient("test-cluster",
@ -120,7 +127,7 @@ public class RedisClusterExtension implements BeforeAllCallback, BeforeEachCallb
}
});
redisCluster.useCluster(connection -> connection.sync().flushall());
redisCluster.useCluster(connection -> connection.sync().flushall(FlushMode.SYNC));
}
public static List<RedisURI> getRedisURIs() {

View File

@ -7,13 +7,12 @@ package org.whispersystems.textsecuregcm.redis;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
import io.lettuce.core.FlushMode;
import io.lettuce.core.RedisURI;
import io.lettuce.core.resource.ClientResources;
import java.io.IOException;
import java.net.ServerSocket;
import java.time.Duration;
import io.lettuce.core.resource.ClientResources;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
@ -22,11 +21,12 @@ import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
import redis.embedded.RedisServer;
import redis.embedded.exceptions.EmbeddedRedisException;
public class RedisServerExtension implements BeforeAllCallback, BeforeEachCallback, AfterAllCallback, AfterEachCallback {
public class RedisServerExtension implements BeforeAllCallback, BeforeEachCallback, ExtensionContext.Store.CloseableResource {
private static RedisServer redisServer;
private static ClientResources redisClientResources;
private FaultTolerantRedisClient faultTolerantRedisClient;
private ClientResources redisClientResources;
public static class RedisServerExtensionBuilder {
@ -46,14 +46,18 @@ public class RedisServerExtension implements BeforeAllCallback, BeforeEachCallba
public void beforeAll(final ExtensionContext context) throws Exception {
assumeFalse(System.getProperty("os.name").equalsIgnoreCase("windows"));
redisServer = RedisServer.builder()
.setting("appendonly no")
.setting("save \"\"")
.setting("dir " + System.getProperty("java.io.tmpdir"))
.port(getAvailablePort())
.build();
if (redisServer == null) {
redisServer = RedisServer.builder()
.setting("appendonly no")
.setting("save \"\"")
.setting("dir " + System.getProperty("java.io.tmpdir"))
.port(getAvailablePort())
.build();
startWithRetries(3);
redisClientResources = ClientResources.builder().build();
startWithRetries(3);
}
}
public static RedisURI getRedisURI() {
@ -62,7 +66,6 @@ public class RedisServerExtension implements BeforeAllCallback, BeforeEachCallba
@Override
public void beforeEach(final ExtensionContext context) {
redisClientResources = ClientResources.builder().build();
final CircuitBreakerConfiguration circuitBreakerConfig = new CircuitBreakerConfiguration();
circuitBreakerConfig.setWaitDurationInOpenState(Duration.ofMillis(500));
faultTolerantRedisClient = new FaultTolerantRedisClient("test-redis-client",
@ -72,19 +75,18 @@ public class RedisServerExtension implements BeforeAllCallback, BeforeEachCallba
circuitBreakerConfig,
new RetryConfiguration());
faultTolerantRedisClient.useConnection(connection -> connection.sync().flushall());
faultTolerantRedisClient.useConnection(connection -> connection.sync().flushall(FlushMode.SYNC));
}
@Override
public void afterEach(final ExtensionContext context) throws InterruptedException {
redisClientResources.shutdown().await();
}
@Override
public void afterAll(final ExtensionContext context) {
public void close() throws Throwable {
if (redisServer != null) {
redisClientResources.shutdown().await();
redisServer.stop();
}
redisClientResources = null;
redisServer = null;
}
public FaultTolerantRedisClient getRedisClient() {

View File

@ -32,7 +32,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
class SecureValueRecovery2ClientTest {
@ -55,7 +55,7 @@ class SecureValueRecovery2ClientTest {
httpExecutor = Executors.newSingleThreadExecutor();
retryExecutor = Executors.newSingleThreadScheduledExecutor();
final SecureValueRecovery2Configuration config = new SecureValueRecovery2Configuration(
final SecureValueRecoveryConfiguration config = new SecureValueRecoveryConfiguration(
"http://localhost:" + wireMock.getPort(),
randomSecretBytes(32),
randomSecretBytes(32),

View File

@ -44,6 +44,7 @@ import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ECSignedPreKey;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
@ -66,6 +67,7 @@ public class AccountCreationDeletionIntegrationTest {
DynamoDbExtensionSchema.Tables.USERNAMES,
DynamoDbExtensionSchema.Tables.EC_KEYS,
DynamoDbExtensionSchema.Tables.PQ_KEYS,
DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS,
DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);
@ -105,7 +107,8 @@ public class AccountCreationDeletionIntegrationTest {
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,
DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()),
mock(ExperimentEnrollmentManager.class));
final ClientPublicKeys clientPublicKeys = new ClientPublicKeys(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
DynamoDbExtensionSchema.Tables.CLIENT_PUBLIC_KEYS.tableName());

View File

@ -36,6 +36,7 @@ import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.ECSignedPreKey;
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
@ -60,6 +61,7 @@ class AccountsManagerChangeNumberIntegrationTest {
Tables.USERNAMES,
Tables.EC_KEYS,
Tables.PQ_KEYS,
Tables.PAGED_PQ_KEYS,
Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,
Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);
@ -96,7 +98,8 @@ class AccountsManagerChangeNumberIntegrationTest {
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,
DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()),
mock(ExperimentEnrollmentManager.class));
final ClientPublicKeys clientPublicKeys = new ClientPublicKeys(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
DynamoDbExtensionSchema.Tables.CLIENT_PUBLIC_KEYS.tableName());

View File

@ -73,6 +73,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
Tables.DELETED_ACCOUNTS,
Tables.EC_KEYS,
Tables.PQ_KEYS,
Tables.PAGED_PQ_KEYS,
Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,
Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);

View File

@ -38,6 +38,7 @@ import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockito.Mockito;
import org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
@ -73,6 +74,7 @@ class AccountsManagerUsernameIntegrationTest {
Tables.PNI_ASSIGNMENTS,
Tables.EC_KEYS,
Tables.PQ_KEYS,
Tables.PAGED_PQ_KEYS,
Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,
Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);
@ -109,7 +111,8 @@ class AccountsManagerUsernameIntegrationTest {
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,
DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()),
mock(ExperimentEnrollmentManager.class));
accounts = Mockito.spy(new Accounts(
Clock.systemUTC(),

View File

@ -36,6 +36,7 @@ import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.entities.DeviceInfo;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
import org.whispersystems.textsecuregcm.redis.RedisServerExtension;
@ -62,6 +63,7 @@ public class AddRemoveDeviceIntegrationTest {
DynamoDbExtensionSchema.Tables.USERNAMES,
DynamoDbExtensionSchema.Tables.EC_KEYS,
DynamoDbExtensionSchema.Tables.PQ_KEYS,
DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS,
DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);
@ -104,7 +106,8 @@ public class AddRemoveDeviceIntegrationTest {
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,
DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));
DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()),
mock(ExperimentEnrollmentManager.class));
final ClientPublicKeys clientPublicKeys = new ClientPublicKeys(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
DynamoDbExtensionSchema.Tables.CLIENT_PUBLIC_KEYS.tableName());

View File

@ -8,18 +8,26 @@ package org.whispersystems.textsecuregcm.storage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterEach;
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.ValueSource;
import org.signal.libsignal.protocol.ecc.Curve;
import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.whispersystems.textsecuregcm.entities.ECPreKey;
import org.whispersystems.textsecuregcm.entities.ECSignedPreKey;
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;
import org.whispersystems.textsecuregcm.tests.util.KeysHelper;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
@ -27,10 +35,15 @@ import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
class KeysManagerTest {
private KeysManager keysManager;
private ExperimentEnrollmentManager experimentEnrollmentManager;
private SingleUseKEMPreKeyStore singleUseKEMPreKeyStore;
private PagedSingleUseKEMPreKeyStore pagedSingleUseKEMPreKeyStore;
@RegisterExtension
static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(
Tables.EC_KEYS, Tables.PQ_KEYS, Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS, Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);
Tables.EC_KEYS, Tables.PQ_KEYS, Tables.PAGED_PQ_KEYS,
Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS, Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);
@RegisterExtension
static final S3LocalStackExtension S3_EXTENSION = new S3LocalStackExtension("testbucket");
@ -43,15 +56,20 @@ class KeysManagerTest {
@BeforeEach
void setup() {
final DynamoDbAsyncClient dynamoDbAsyncClient = DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient();
experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
singleUseKEMPreKeyStore = new SingleUseKEMPreKeyStore(dynamoDbAsyncClient, Tables.PQ_KEYS.tableName());
pagedSingleUseKEMPreKeyStore = new PagedSingleUseKEMPreKeyStore(dynamoDbAsyncClient,
S3_EXTENSION.getS3Client(),
DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS.tableName(),
S3_EXTENSION.getBucketName());
keysManager = new KeysManager(
new SingleUseECPreKeyStore(dynamoDbAsyncClient, Tables.EC_KEYS.tableName()),
new SingleUseKEMPreKeyStore(dynamoDbAsyncClient, Tables.PQ_KEYS.tableName()),
new PagedSingleUseKEMPreKeyStore(dynamoDbAsyncClient,
S3_EXTENSION.getS3Client(),
DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS.tableName(),
S3_EXTENSION.getBucketName()),
singleUseKEMPreKeyStore,
pagedSingleUseKEMPreKeyStore,
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient, Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient, Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient, Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()),
experimentEnrollmentManager);
}
@Test
@ -67,18 +85,58 @@ class KeysManagerTest {
"Repeatedly storing same key should have no effect");
}
@Test
void storeKemOneTimePreKeys() {
@ParameterizedTest
@ValueSource(booleans = {true, false})
void storeKemOneTimePreKeysClearsOld(boolean inPagedExperiment) {
final List<KEMSignedPreKey> oldPreKeys = List.of(generateTestKEMSignedPreKey(1));
// Leave a key in the 'other' key store
(inPagedExperiment
? singleUseKEMPreKeyStore.store(ACCOUNT_UUID, DEVICE_ID, oldPreKeys)
: pagedSingleUseKEMPreKeyStore.store(ACCOUNT_UUID, DEVICE_ID, oldPreKeys))
.join();
when(experimentEnrollmentManager.isEnrolled(ACCOUNT_UUID, KeysManager.PAGED_KEYS_EXPERIMENT_NAME))
.thenReturn(inPagedExperiment);
final List<KEMSignedPreKey> newPreKeys = List.of(generateTestKEMSignedPreKey(2));
keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, newPreKeys).join();
final int expectedPagedKeyCount = inPagedExperiment ? 1 : 0;
final int expectedUnpagedKeyCount = 1 - expectedPagedKeyCount;
assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());
assertEquals(expectedPagedKeyCount, pagedSingleUseKEMPreKeyStore.getCount(ACCOUNT_UUID, DEVICE_ID).join());
assertEquals(expectedUnpagedKeyCount, singleUseKEMPreKeyStore.getCount(ACCOUNT_UUID, DEVICE_ID).join());
final KEMSignedPreKey key = keysManager.takePQ(ACCOUNT_UUID, DEVICE_ID).join().orElseThrow();
assertEquals(2, key.keyId());
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void storeKemOneTimePreKeys(boolean inPagedExperiment) {
assertEquals(0, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join(),
"Initial pre-key count for an account should be zero");
keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestKEMSignedPreKey(1))).join();
assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());
when(experimentEnrollmentManager.isEnrolled(ACCOUNT_UUID, KeysManager.PAGED_KEYS_EXPERIMENT_NAME))
.thenReturn(inPagedExperiment);
final int expectedPagedKeyCount = inPagedExperiment ? 1 : 0;
final int expectedUnpagedKeyCount = 1 - expectedPagedKeyCount;
keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestKEMSignedPreKey(1))).join();
assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());
assertEquals(expectedPagedKeyCount, pagedSingleUseKEMPreKeyStore.getCount(ACCOUNT_UUID, DEVICE_ID).join());
assertEquals(expectedUnpagedKeyCount, singleUseKEMPreKeyStore.getCount(ACCOUNT_UUID, DEVICE_ID).join());
keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestKEMSignedPreKey(1))).join();
assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());
assertEquals(expectedPagedKeyCount, pagedSingleUseKEMPreKeyStore.getCount(ACCOUNT_UUID, DEVICE_ID).join());
assertEquals(expectedUnpagedKeyCount, singleUseKEMPreKeyStore.getCount(ACCOUNT_UUID, DEVICE_ID).join());
}
@Test
void storeEcSignedPreKeys() {
assertTrue(keysManager.getEcSignedPreKey(ACCOUNT_UUID, DEVICE_ID).join().isEmpty());
@ -128,9 +186,24 @@ class KeysManagerTest {
}
@Test
void testDeleteSingleUsePreKeysByAccount() {
void takeWithExistingExperimentalKey() {
// Put a key in the new store, even though we're not in the experiment. This simulates a take when operating
// in mixed mode on experiment rollout
pagedSingleUseKEMPreKeyStore.store(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestKEMSignedPreKey(1))).join();
assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());
assertEquals(1, keysManager.takePQ(ACCOUNT_UUID, DEVICE_ID).join().orElseThrow().keyId());
assertEquals(0, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void testDeleteSingleUsePreKeysByAccount(final boolean inPagedExperiment) {
int keyId = 1;
when(experimentEnrollmentManager.isEnrolled(ACCOUNT_UUID, KeysManager.PAGED_KEYS_EXPERIMENT_NAME))
.thenReturn(inPagedExperiment);
for (byte deviceId : new byte[] {DEVICE_ID, DEVICE_ID + 1}) {
keysManager.storeEcOneTimePreKeys(ACCOUNT_UUID, deviceId, List.of(generateTestPreKey(keyId++))).join();
keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, deviceId, List.of(generateTestKEMSignedPreKey(keyId++))).join();
@ -155,10 +228,14 @@ class KeysManagerTest {
}
}
@Test
void testDeleteSingleUsePreKeysByAccountAndDevice() {
@ParameterizedTest
@ValueSource(booleans = {true, false})
void testDeleteSingleUsePreKeysByAccountAndDevice(final boolean inPagedExperiment) {
int keyId = 1;
when(experimentEnrollmentManager.isEnrolled(ACCOUNT_UUID, KeysManager.PAGED_KEYS_EXPERIMENT_NAME))
.thenReturn(inPagedExperiment);
for (byte deviceId : new byte[] {DEVICE_ID, DEVICE_ID + 1}) {
keysManager.storeEcOneTimePreKeys(ACCOUNT_UUID, deviceId, List.of(generateTestPreKey(keyId++))).join();
keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, deviceId, List.of(generateTestKEMSignedPreKey(keyId++))).join();

View File

@ -42,6 +42,7 @@ 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.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
import org.whispersystems.textsecuregcm.util.MockUtils;
@ -191,6 +192,15 @@ class GooglePlayBillingManagerTest {
verifyNoInteractions(cancel);
}
@Test
public void handle429() throws IOException {
final HttpResponseException mockException = mock(HttpResponseException.class);
when(mockException.getStatusCode()).thenReturn(429);
when(subscriptionsv2Get.execute()).thenThrow(mockException);
CompletableFutureTestUtil.assertFailsWithCause(
RateLimitExceededException.class, googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN));
}
@Test
public void getReceiptUnacknowledged() throws IOException {
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()

View File

@ -53,6 +53,9 @@ directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789
svr2.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users
svr2.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users
svrb.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth tokens for Signal users
svrb.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth identity tokens for Signal users
tus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG=
# The below private key was key generated exclusively for testing purposes. Do not use it in any other context.

View File

@ -223,6 +223,35 @@ svr2:
9Kxq0DY7RCEpdHMCKcOL
-----END CERTIFICATE-----
svrb:
uri: svrb.example.com
userAuthenticationTokenSharedSecret: secret://svrb.userAuthenticationTokenSharedSecret
userIdTokenSharedSecret: secret://svrb.userIdTokenSharedSecret
svrCaCertificates:
# this is a randomly generated test certificate
- |
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUW5lcNWkuynRVc8Rq5pO6mHQBuZAwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAzMjUwMzE4MTNaFw0yOTAz
MjQwMzE4MTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCfH4Um+fv2r4KudhD37/UXp8duRLTmp4XvpBTpDHpD
2HF8p2yThVKlJnMkP/9Ey1Rb0vhxO7DCltLdW8IYcxJuHoyMvyhGUEtxxkOZbrk8
ciUR9jTZ37x7vXRGj/RxcdlS6iD0MeF0D/LAkImt4T/kiKwDbENrVEnYWJmipCKP
ribxWky7HqxDCoYMQr0zatxB3A9mx5stH+H3kbw3CZcm+ugF9ZIKDEVHb0lf28gq
llmD120q/vs9YV3rzVL7sBGDqf6olkulvHQJKElZg2rdcHWFcngSlU2BjR04oyuH
c/SSiLSB3YB0tdFGta5uorXyV1y7RElPeBfOfvEjsG3TAgMBAAGjUzBRMB0GA1Ud
DgQWBBQX+xlgSWWbDjv0SrJ+h67xauJ80zAfBgNVHSMEGDAWgBQX+xlgSWWbDjv0
SrJ+h67xauJ80zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw
ZG2MCCjscn6h/QOoJU+IDfa68OqLq0I37gMnLMde4yEhAmm//miePIq4Uz9GRJ+h
rAmdEnspKgyQ93PjF7Xpk/JdJA4B1bIrsOl/cSwqx2sFhRt8Kt1DHGlGWXqOaHRP
UkZ86MyRL3sXly6WkxEYxZJeQaOzMy2XmQh7grzrlTBuSI+0xf7vsRRDipxr6LVQ
6qGWyGODLLc2JD1IXj/1HpRVT2LoGGlKMuyxACQAm4oak1vvJ9mGxgfd9AU+eo58
O/esB2Eaf+QqMPELdFSZQfG2jvp+3WQTZK8fDKHyLr076G3UetEMy867F6fzTSZd
9Kxq0DY7RCEpdHMCKcOL
-----END CERTIFICATE-----
messageCache: # Redis server configuration for message store cache
persistDelayMinutes: 1
cluster:

@ -1 +1 @@
Subproject commit 1fd829b5aa81c31a3f4999dabe0111923748036c
Subproject commit 01c0bbfd6253e9532802e789dd801e9673ae4c2c