Compare commits

...

39 Commits

Author SHA1 Message Date
erik-signal
56e54e0724 Update to the latest version of the abusive message filter 2022-10-05 13:19:47 -04:00
erik-signal
544e4fb89a Adjust routing for stories. 2022-10-05 12:20:42 -04:00
erik-signal
966c3a8f47 Add routing for stories. 2022-10-05 10:44:50 -04:00
Ravi Khadiwala
c2ab72c77e Update to the latest version of the abusive message filter 2022-09-30 12:57:21 -05:00
Ravi Khadiwala
4468ee3142 Update to the latest version of the abusive message filter 2022-09-30 12:10:02 -05:00
Ravi Khadiwala
c82c2c0ba4 Add country tag to twilio failures 2022-09-30 12:03:46 -05:00
Ravi Khadiwala
6e595a0959 add an optionals utility and fix push challenge metric 2022-09-30 12:02:47 -05:00
Ravi Khadiwala
a79d709039 Return 403 when a push challenge is incorrect 2022-09-30 12:02:47 -05:00
Ravi Khadiwala
538a07542e Update to the latest version of the abusive message filter 2022-09-22 11:20:48 -05:00
Ravi Khadiwala
07ed765250 Update abusive message filter and filter account creates 2022-09-20 14:52:18 -05:00
Ravi Khadiwala
2e497b5834 Fix operator order in metric calculation 2022-09-15 14:04:18 -05:00
Ravi Khadiwala
61b3cecd17 Fix missing increment on recaptcha counter 2022-09-14 17:07:26 -05:00
Ravi Khadiwala
a4a666bb80 Add metrics for recaptcha reasons 2022-09-14 16:00:11 -05:00
Ravi Khadiwala
c14621a09f Add metrics for captcha scores 2022-09-14 16:00:11 -05:00
Ravi Khadiwala
d0a8899daf Change discriminator seperator and default width 2022-09-14 15:53:15 -05:00
Chris Eager
65dbcb3e5f Remove duplicate bom from dependencyManagement 2022-09-12 16:54:31 -05:00
Chris Eager
7f725b67c4 Update to the latest version of the abusive message filter 2022-09-12 11:24:37 -05:00
Chris Eager
e25252dc69 Remove unused exception 2022-09-12 11:19:15 -05:00
Chris Eager
8b65c11e1e Update batch check entities from two optional fields to a single field 2022-09-12 11:19:01 -05:00
Chris Eager
320c5eac53 Add support for PNIs at v1/profile/identity_check/batch 2022-09-09 10:55:34 -05:00
Ehren Kret
8199e0d2d5 Set resource field on log entry 2022-09-07 19:37:26 -05:00
Ehren Kret
53387f5a0c Register polymorphic serialization 2022-09-07 19:37:26 -05:00
Ehren Kret
7d171a79d7 Remove redundant @NotNull annotation 2022-09-07 19:37:26 -05:00
Ehren Kret
3b99bb9e78 Log remote config delete events 2022-09-07 19:37:26 -05:00
Ehren Kret
132f026c75 Improve readability of event code 2022-09-07 19:37:26 -05:00
Ehren Kret
abd0f9630c Create GCP Logging implementation of AdminEventLogger 2022-09-07 19:37:26 -05:00
Ehren Kret
a4508ec84f Add new event logging module 2022-09-07 19:37:26 -05:00
Ehren Kret
6119b6ab89 Upgrade java-uuid-generator dependency 2022-09-07 19:37:26 -05:00
Ehren Kret
307ac47ce0 Update DynamoDBLocal dependency version 2022-09-07 19:37:26 -05:00
Ravi Khadiwala
4032ddd4fd Add reserve/confirm for usernames 2022-09-07 11:49:49 -05:00
Chris Eager
98c8dc05f1 Update to the latest version of the abusive message filter 2022-09-07 11:49:01 -05:00
Chris Eager
4c677ec2da Remove deprecated /v1/attachments 2022-09-07 11:48:16 -05:00
Chris Eager
c05692e417 Update deprecated CircuitBreakerConfig usage 2022-09-07 11:47:15 -05:00
Chris Eager
1e7aa89664 Update resilience4j to 1.7.0 2022-09-07 11:47:15 -05:00
gram-signal
ae1edf3c5c Remove experiment associated with auth1->auth2 rollout. 2022-08-31 12:10:46 -06:00
gram-signal
b17f41c3e8 Check if dashes work in dynamic configuration keys. 2022-08-29 15:51:37 -06:00
gram-signal
08db4ba54b Update authentication to use HKDF_SHA256. 2022-08-29 14:20:47 -06:00
gram-signal
cb6cc39679 Ignore null identity key. 2022-08-29 13:26:49 -06:00
Jon Chambers
b6bf6c994c Remove a spurious @Nullable annotation 2022-08-26 15:22:23 -04:00
91 changed files with 2279 additions and 799 deletions

View File

@@ -1135,7 +1135,7 @@ ij_kotlin_field_annotation_wrap = split_into_lines
ij_kotlin_finally_on_new_line = false
ij_kotlin_if_rparen_on_new_line = false
ij_kotlin_import_nested_classes = false
ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^
ij_kotlin_imports_layout = *
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
ij_kotlin_keep_blank_lines_before_right_brace = 2
ij_kotlin_keep_blank_lines_in_code = 2
@@ -1151,9 +1151,9 @@ ij_kotlin_method_call_chain_wrap = off
ij_kotlin_method_parameters_new_line_after_left_paren = false
ij_kotlin_method_parameters_right_paren_on_new_line = false
ij_kotlin_method_parameters_wrap = off
ij_kotlin_name_count_to_use_star_import = 5
ij_kotlin_name_count_to_use_star_import_for_members = 3
ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.**
ij_kotlin_name_count_to_use_star_import = 999
ij_kotlin_name_count_to_use_star_import_for_members = 999
ij_kotlin_packages_to_use_import_on_demand =
ij_kotlin_parameter_annotation_wrap = off
ij_kotlin_space_after_comma = true
ij_kotlin_space_after_extend_colon = true

77
event-logger/pom.xml Normal file
View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2022 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>TextSecureServer</artifactId>
<groupId>org.whispersystems.textsecure</groupId>
<version>JGITVER</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>event-logger</artifactId>
<dependencies>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-logging</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
<version>${kotlinx-serialization.version}</version>
</dependency>
</dependencies>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
<configuration>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-serialization</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.event
import java.util.Collections
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
val module = SerializersModule {
polymorphic(Event::class) {
subclass(RemoteConfigSetEvent::class)
subclass(RemoteConfigDeleteEvent::class)
}
}
val jsonFormat = Json { serializersModule = module }
sealed interface Event
@Serializable
data class RemoteConfigSetEvent(
val token: String,
val name: String,
val percentage: Int,
val defaultValue: String? = null,
val value: String? = null,
val hashKey: String? = null,
val uuids: Collection<String> = Collections.emptyList(),
) : Event
@Serializable
data class RemoteConfigDeleteEvent(
val token: String,
val name: String,
) : Event

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.event
import com.google.cloud.logging.LogEntry
import com.google.cloud.logging.Logging
import com.google.cloud.logging.MonitoredResourceUtil
import com.google.cloud.logging.Payload.JsonPayload
import com.google.cloud.logging.Severity
import com.google.protobuf.Struct
import com.google.protobuf.util.JsonFormat
import kotlinx.serialization.encodeToString
interface AdminEventLogger {
fun logEvent(event: Event, labels: Map<String, String>?)
fun logEvent(event: Event) = logEvent(event, null)
}
class NoOpAdminEventLogger : AdminEventLogger {
override fun logEvent(event: Event, labels: Map<String, String>?) {}
}
class GoogleCloudAdminEventLogger(private val logging: Logging, private val projectId: String, private val logName: String) : AdminEventLogger {
override fun logEvent(event: Event, labels: Map<String, String>?) {
val structBuilder = Struct.newBuilder()
JsonFormat.parser().merge(jsonFormat.encodeToString(event), structBuilder)
val struct = structBuilder.build()
val logEntryBuilder = LogEntry.newBuilder(JsonPayload.of(struct))
.setLogName(logName)
.setSeverity(Severity.NOTICE)
.setResource(MonitoredResourceUtil.getResource(projectId, "project"));
if (labels != null) {
logEntryBuilder.setLabels(labels);
}
logging.write(listOf(logEntryBuilder.build()))
}
}

View File

@@ -35,6 +35,7 @@
</pluginRepositories>
<modules>
<module>event-logger</module>
<module>redis-dispatch</module>
<module>websocket-resources</module>
<module>service</module>
@@ -53,6 +54,8 @@
<jackson.version>2.13.3</jackson.version>
<jaxb.version>2.3.1</jaxb.version>
<jedis.version>2.9.0</jedis.version>
<kotlin.version>1.7.10</kotlin.version>
<kotlinx-serialization.version>1.4.0</kotlinx-serialization.version>
<lettuce.version>6.1.9.RELEASE</lettuce.version>
<libphonenumber.version>8.12.54</libphonenumber.version>
<logstash.logback.version>7.0.1</logstash.logback.version>
@@ -62,7 +65,7 @@
<opentest4j.version>1.2.0</opentest4j.version>
<protobuf.version>3.19.4</protobuf.version>
<pushy.version>0.15.1</pushy.version>
<resilience4j.version>1.5.0</resilience4j.version>
<resilience4j.version>1.7.0</resilience4j.version>
<semver4j.version>3.1.0</semver4j.version>
<slf4j.version>1.7.30</slf4j.version>
<stripe.version>21.2.0</stripe.version>
@@ -115,7 +118,7 @@
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>libraries-bom</artifactId>
<version>20.9.0</version>
<version>26.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -133,7 +136,6 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.eatthepath</groupId>
<artifactId>pushy</artifactId>

View File

@@ -3,6 +3,13 @@
# `unset` values will need to be set to work properly.
# Most other values are technically valid for a local/demonstration environment, but are probably not production-ready.
adminEventLoggingConfiguration:
credentials: |
Some credentials text
blah blah blah
projectId: some-project-id
logName: some-log-name
stripe:
apiKey: unset
idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash

View File

@@ -24,6 +24,11 @@
<artifactId>jakarta.ws.rs-api</artifactId>
</dependency>
<dependency>
<groupId>org.whispersystems.textsecure</groupId>
<artifactId>event-logger</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.whispersystems.textsecure</groupId>
<artifactId>redis-dispatch</artifactId>
@@ -289,10 +294,6 @@
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-core</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>dynamodb-lock-client</artifactId>
@@ -408,14 +409,14 @@
<dependency>
<groupId>com.fasterxml.uuid</groupId>
<artifactId>java-uuid-generator</artifactId>
<version>3.2.0</version>
<version>4.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>DynamoDBLocal</artifactId>
<version>1.17.2</version>
<version>1.19.0</version>
<scope>test</scope>
</dependency>

View File

@@ -14,6 +14,7 @@ import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.configuration.AbusiveMessageFilterConfiguration;
import org.whispersystems.textsecuregcm.configuration.AccountDatabaseCrawlerConfiguration;
import org.whispersystems.textsecuregcm.configuration.AdminEventLoggingConfiguration;
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
import org.whispersystems.textsecuregcm.configuration.AppConfigConfiguration;
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
@@ -53,6 +54,11 @@ import org.whispersystems.websocket.configuration.WebSocketConfiguration;
/** @noinspection MismatchedQueryAndUpdateOfCollection, WeakerAccess */
public class WhisperServerConfiguration extends Configuration {
@NotNull
@Valid
@JsonProperty
private AdminEventLoggingConfiguration adminEventLoggingConfiguration;
@NotNull
@Valid
@JsonProperty
@@ -257,6 +263,10 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private AbusiveMessageFilterConfiguration abusiveMessageFilter;
public AdminEventLoggingConfiguration getAdminEventLoggingConfiguration() {
return adminEventLoggingConfiguration;
}
public StripeConfiguration getStripe() {
return stripe;
}

View File

@@ -14,6 +14,8 @@ import com.codahale.metrics.SharedMetricRegistries;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.cloud.logging.LoggingOptions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
@@ -34,7 +36,9 @@ import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
import io.micrometer.datadog.DatadogMeterRegistry;
import java.io.ByteArrayInputStream;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
@@ -55,6 +59,8 @@ import javax.servlet.FilterRegistration;
import javax.servlet.ServletRegistration;
import org.eclipse.jetty.servlets.CrossOriginFilter;
import org.glassfish.jersey.server.ServerProperties;
import org.signal.event.AdminEventLogger;
import org.signal.event.GoogleCloudAdminEventLogger;
import org.signal.i18n.HeaderControlledResourceBundleLookup;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;
@@ -80,7 +86,6 @@ import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator;
import org.whispersystems.textsecuregcm.configuration.DirectoryServerConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV1;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
import org.whispersystems.textsecuregcm.controllers.CertificateController;
@@ -119,7 +124,6 @@ import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitChallengeExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.ApplicationShutdownMonitor;
@@ -136,7 +140,6 @@ import org.whispersystems.textsecuregcm.metrics.MicrometerRegistryManager;
import org.whispersystems.textsecuregcm.metrics.NetworkReceivedGauge;
import org.whispersystems.textsecuregcm.metrics.NetworkSentGauge;
import org.whispersystems.textsecuregcm.metrics.OperatingSystemMemoryGauge;
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener;
import org.whispersystems.textsecuregcm.metrics.TrafficSource;
import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider;
@@ -148,6 +151,7 @@ import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.push.FcmSender;
import org.whispersystems.textsecuregcm.push.MessageSender;
import org.whispersystems.textsecuregcm.push.ProvisioningManager;
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
@@ -188,6 +192,7 @@ import org.whispersystems.textsecuregcm.storage.NonNormalizedAccountCrawlerListe
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.storage.PubSubManager;
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
@@ -196,7 +201,6 @@ import org.whispersystems.textsecuregcm.storage.RemoteConfigs;
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
@@ -338,7 +342,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getDynamoDbTables().getAccounts().getScanPageSize());
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
config.getDynamoDbTables().getReservedUsernames().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
config.getDynamoDbTables().getProfiles().getTableName());
@@ -407,6 +411,14 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.rejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy())
.build();
final AdminEventLogger adminEventLogger = new GoogleCloudAdminEventLogger(
LoggingOptions.newBuilder().setProjectId(config.getAdminEventLoggingConfiguration().projectId())
.setCredentials(GoogleCredentials.fromStream(new ByteArrayInputStream(
config.getAdminEventLoggingConfiguration().credentials().getBytes(StandardCharsets.UTF_8))))
.build().getService(),
config.getAdminEventLoggingConfiguration().projectId(),
config.getAdminEventLoggingConfiguration().logName());
StripeManager stripeManager = new StripeManager(config.getStripe().getApiKey(), stripeExecutor,
config.getStripe().getIdempotencyKeyGenerator(), config.getStripe().getBoostDescription());
@@ -448,7 +460,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
experimentEnrollmentManager, clock);
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
@@ -632,7 +644,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));
final List<Object> commonControllers = Lists.newArrayList(
new AttachmentControllerV1(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getBucket()),
new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getRegion(), config.getAwsAttachmentsConfiguration().getBucket()),
new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().getDomain(), config.getGcpAttachmentsConfiguration().getEmail(), config.getGcpAttachmentsConfiguration().getMaxSizeInBytes(), config.getGcpAttachmentsConfiguration().getPathPrefix(), config.getGcpAttachmentsConfiguration().getRsaSigningKey()),
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays()), zkAuthOperations, clock),
@@ -646,7 +657,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
new ProvisioningController(rateLimiters, provisioningManager),
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().getAuthorizedTokens(), config.getRemoteConfigConfiguration().getGlobalConfig()),
new RemoteConfigController(remoteConfigsManager, adminEventLogger, config.getRemoteConfigConfiguration().getAuthorizedTokens(), config.getRemoteConfigConfiguration().getGlobalConfig()),
new SecureBackupController(backupCredentialsGenerator),
new SecureStorageController(storageCredentialsGenerator),
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
@@ -705,13 +716,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
registerCorsFilter(environment);
registerExceptionMappers(environment, webSocketEnvironment, provisioningEnvironment);
RateLimitChallengeExceptionMapper rateLimitChallengeExceptionMapper =
new RateLimitChallengeExceptionMapper(rateLimitChallengeOptionManager);
environment.jersey().register(rateLimitChallengeExceptionMapper);
webSocketEnvironment.jersey().register(rateLimitChallengeExceptionMapper);
provisioningEnvironment.jersey().register(rateLimitChallengeExceptionMapper);
environment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
provisioningEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);

View File

@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.auth;
import io.dropwizard.auth.Authenticator;
import io.dropwizard.auth.basic.BasicCredentials;
import java.util.Optional;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
public class AccountAuthenticator extends BaseAccountAuthenticator implements

View File

@@ -4,7 +4,9 @@
*/
package org.whispersystems.textsecuregcm.auth;
import com.google.common.annotations.VisibleForTesting;
import org.apache.commons.codec.binary.Hex;
import org.signal.libsignal.protocol.kdf.HKDF;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -12,10 +14,18 @@ import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
public class AuthenticationCredentials {
private static final String V2_PREFIX = "2.";
private final String hashedAuthenticationToken;
private final String salt;
public enum Version {
V1,
V2,
}
public static final Version CURRENT_VERSION = Version.V2;
public AuthenticationCredentials(String hashedAuthenticationToken, String salt) {
this.hashedAuthenticationToken = hashedAuthenticationToken;
this.salt = salt;
@@ -23,7 +33,20 @@ public class AuthenticationCredentials {
public AuthenticationCredentials(String authenticationToken) {
this.salt = String.valueOf(Math.abs(new SecureRandom().nextInt()));
this.hashedAuthenticationToken = getHashedValue(salt, authenticationToken);
this.hashedAuthenticationToken = getV2HashedValue(salt, authenticationToken);
}
@VisibleForTesting
public AuthenticationCredentials v1ForTesting(String authenticationToken) {
String salt = String.valueOf(Math.abs(new SecureRandom().nextInt()));
return new AuthenticationCredentials(getV1HashedValue(salt, authenticationToken), salt);
}
public Version getVersion() {
if (this.hashedAuthenticationToken.startsWith(V2_PREFIX)) {
return Version.V2;
}
return Version.V1;
}
public String getHashedAuthenticationToken() {
@@ -35,11 +58,14 @@ public class AuthenticationCredentials {
}
public boolean verify(String authenticationToken) {
String theirValue = getHashedValue(salt, authenticationToken);
final String theirValue = switch (getVersion()) {
case V1 -> getV1HashedValue(salt, authenticationToken);
case V2 -> getV2HashedValue(salt, authenticationToken);
};
return MessageDigest.isEqual(theirValue.getBytes(StandardCharsets.UTF_8), this.hashedAuthenticationToken.getBytes(StandardCharsets.UTF_8));
}
private static String getHashedValue(String salt, String token) {
private static String getV1HashedValue(String salt, String token) {
try {
return new String(Hex.encodeHex(MessageDigest.getInstance("SHA1").digest((salt + token).getBytes(StandardCharsets.UTF_8))));
} catch (NoSuchAlgorithmException e) {
@@ -47,4 +73,13 @@ public class AuthenticationCredentials {
}
}
private static final byte[] AUTH_TOKEN_HKDF_INFO = "authtoken".getBytes(StandardCharsets.UTF_8);
private static String getV2HashedValue(String salt, String token) {
byte[] secret = HKDF.deriveSecrets(
token.getBytes(StandardCharsets.UTF_8), // key
salt.getBytes(StandardCharsets.UTF_8), // salt
AUTH_TOKEN_HKDF_INFO,
32);
return V2_PREFIX + Hex.encodeHexString(secret);
}
}

View File

@@ -43,8 +43,8 @@ public class BaseAccountAuthenticator {
@VisibleForTesting
public BaseAccountAuthenticator(AccountsManager accountsManager, Clock clock) {
this.accountsManager = accountsManager;
this.clock = clock;
this.accountsManager = accountsManager;
this.clock = clock;
}
static Pair<String, Long> getIdentifierAndDeviceId(final String basicUsername) {
@@ -104,9 +104,16 @@ public class BaseAccountAuthenticator {
}
}
if (device.get().getAuthenticationCredentials().verify(basicCredentials.getPassword())) {
AuthenticationCredentials deviceAuthenticationCredentials = device.get().getAuthenticationCredentials();
if (deviceAuthenticationCredentials.verify(basicCredentials.getPassword())) {
succeeded = true;
final Account authenticatedAccount = updateLastSeen(account.get(), device.get());
Account authenticatedAccount = updateLastSeen(account.get(), device.get());
if (deviceAuthenticationCredentials.getVersion() != AuthenticationCredentials.CURRENT_VERSION) {
authenticatedAccount = accountsManager.updateDeviceAuthentication(
authenticatedAccount,
device.get(),
new AuthenticationCredentials(basicCredentials.getPassword())); // new credentials have current version
}
return Optional.of(new AuthenticatedAccount(
new RefreshingAccountAndDeviceSupplier(authenticatedAccount, device.get().getId(), accountsManager)));
}
@@ -142,5 +149,4 @@ public class BaseAccountAuthenticator {
return account;
}
}

View File

@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.auth;
import io.dropwizard.auth.Authenticator;
import io.dropwizard.auth.basic.BasicCredentials;
import java.util.Optional;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
public class DisabledPermittedAccountAuthenticator extends BaseAccountAuthenticator implements

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import javax.validation.constraints.NotEmpty;
public record AdminEventLoggingConfiguration(
@NotEmpty String credentials,
@NotEmpty String projectId,
@NotEmpty String logName) {
}

View File

@@ -27,12 +27,17 @@ public class CircuitBreakerConfiguration {
@JsonProperty
@NotNull
@Min(1)
private int ringBufferSizeInHalfOpenState = 10;
private int permittedNumberOfCallsInHalfOpenState = 10;
@JsonProperty
@NotNull
@Min(1)
private int ringBufferSizeInClosedState = 100;
private int slidingWindowSize = 100;
@JsonProperty
@NotNull
@Min(1)
private int slidingWindowMinimumNumberOfCalls = 100;
@JsonProperty
@NotNull
@@ -47,28 +52,32 @@ public class CircuitBreakerConfiguration {
return failureRateThreshold;
}
public int getRingBufferSizeInHalfOpenState() {
return ringBufferSizeInHalfOpenState;
public int getPermittedNumberOfCallsInHalfOpenState() {
return permittedNumberOfCallsInHalfOpenState;
}
public int getRingBufferSizeInClosedState() {
return ringBufferSizeInClosedState;
public int getSlidingWindowSize() {
return slidingWindowSize;
}
public int getSlidingWindowMinimumNumberOfCalls() {
return slidingWindowMinimumNumberOfCalls;
}
public long getWaitDurationInOpenStateInSeconds() {
return waitDurationInOpenStateInSeconds;
}
public List<Class> getIgnoredExceptions() {
return ignoredExceptions.stream()
.map(name -> {
try {
return Class.forName(name);
} catch (final ClassNotFoundException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
public List<Class<?>> getIgnoredExceptions() {
return ignoredExceptions.stream()
.map(name -> {
try {
return Class.forName(name);
} catch (final ClassNotFoundException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
}
@VisibleForTesting
@@ -77,13 +86,18 @@ public class CircuitBreakerConfiguration {
}
@VisibleForTesting
public void setRingBufferSizeInClosedState(int size) {
this.ringBufferSizeInClosedState = size;
public void setSlidingWindowSize(int size) {
this.slidingWindowSize = size;
}
@VisibleForTesting
public void setRingBufferSizeInHalfOpenState(int size) {
this.ringBufferSizeInHalfOpenState = size;
public void setSlidingWindowMinimumNumberOfCalls(int size) {
this.slidingWindowMinimumNumberOfCalls = size;
}
@VisibleForTesting
public void setPermittedNumberOfCallsInHalfOpenState(int size) {
this.permittedNumberOfCallsInHalfOpenState = size;
}
@VisibleForTesting
@@ -98,11 +112,12 @@ public class CircuitBreakerConfiguration {
public CircuitBreakerConfig toCircuitBreakerConfig() {
return CircuitBreakerConfig.custom()
.failureRateThreshold(getFailureRateThreshold())
.ignoreExceptions(getIgnoredExceptions().toArray(new Class[0]))
.ringBufferSizeInHalfOpenState(getRingBufferSizeInHalfOpenState())
.waitDurationInOpenState(Duration.ofSeconds(getWaitDurationInOpenStateInSeconds()))
.ringBufferSizeInClosedState(getRingBufferSizeInClosedState())
.build();
.failureRateThreshold(getFailureRateThreshold())
.ignoreExceptions(getIgnoredExceptions().toArray(new Class[0]))
.permittedNumberOfCallsInHalfOpenState(getPermittedNumberOfCallsInHalfOpenState())
.waitDurationInOpenState(Duration.ofSeconds(getWaitDurationInOpenStateInSeconds()))
.slidingWindow(getSlidingWindowSize(), getSlidingWindowMinimumNumberOfCalls(),
CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.build();
}
}

View File

@@ -62,9 +62,15 @@ public class RateLimitsConfiguration {
@JsonProperty
private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
@JsonProperty
private RateLimitConfiguration usernameReserve = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
@JsonProperty
private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0);
@JsonProperty
private RateLimitConfiguration stories = new RateLimitConfiguration(10_000, 10_000 / (24.0 * 60.0));
public RateLimitConfiguration getAutoBlock() {
return autoBlock;
}
@@ -137,10 +143,16 @@ public class RateLimitsConfiguration {
return usernameSet;
}
public RateLimitConfiguration getUsernameReserve() {
return usernameReserve;
}
public RateLimitConfiguration getCheckAccountExistence() {
return checkAccountExistence;
}
public RateLimitConfiguration getStories() { return stories; }
public static class RateLimitConfiguration {
@JsonProperty
private int bucketSize;

View File

@@ -7,12 +7,13 @@ package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.validation.constraints.Min;
import java.time.Duration;
public class UsernameConfiguration {
@JsonProperty
@Min(1)
private int discriminatorInitialWidth = 4;
private int discriminatorInitialWidth = 2;
@JsonProperty
@Min(1)
@@ -22,6 +23,9 @@ public class UsernameConfiguration {
@Min(1)
private int attemptsPerWidth = 10;
@JsonProperty
private Duration reservationTtl = Duration.ofMinutes(5);
public int getDiscriminatorInitialWidth() {
return discriminatorInitialWidth;
}
@@ -33,4 +37,8 @@ public class UsernameConfiguration {
public int getAttemptsPerWidth() {
return attemptsPerWidth;
}
public Duration getReservationTtl() {
return reservationTtl;
}
}

View File

@@ -50,6 +50,7 @@ import javax.ws.rs.core.Response.Status;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.abuse.FilterAbusiveMessages;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
@@ -68,11 +69,14 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
import org.whispersystems.textsecuregcm.entities.DeviceName;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
@@ -92,11 +96,13 @@ import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
import org.whispersystems.textsecuregcm.util.Hex;
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
import org.whispersystems.textsecuregcm.util.Optionals;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.util.VerificationCode;
@@ -121,6 +127,7 @@ public class AccountController {
private static final String CHALLENGE_ISSUED_COUNTER_NAME = name(AccountController.class, "challengeIssued");
private static final String TWILIO_VERIFY_ERROR_COUNTER_NAME = name(AccountController.class, "twilioVerifyError");
private static final String TWILIO_VERIFY_UNDELIVERED_COUNTER_NAME = name(AccountController.class, "twilioUndelivered");
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(AccountController.class, "invalidAcceptLanguage");
private static final String NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
@@ -130,6 +137,7 @@ public class AccountController {
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
private static final String REGION_TAG_NAME = "region";
private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport";
private static final String SCORE_TAG_NAME = "score";
private static final String VERIFY_EXPERIMENT_TAG_NAME = "twilioVerify";
@@ -211,6 +219,7 @@ public class AccountController {
@Timed
@GET
@Path("/{transport}/code/{number}")
@FilterAbusiveMessages
@Produces(MediaType.APPLICATION_JSON)
public Response createAccount(@PathParam("transport") String transport,
@PathParam("number") String number,
@@ -227,23 +236,39 @@ public class AccountController {
String sourceHost = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow();
Optional<StoredVerificationCode> storedChallenge = pendingAccounts.getCodeForNumber(number);
CaptchaRequirement requirement = requiresCaptcha(number, transport, forwardedFor, sourceHost, captcha,
storedChallenge, pushChallenge, userAgent);
if (requirement.isCaptchaRequired()) {
final String countryCode = Util.getCountryCode(number);
final String region = Util.getRegion(number);
// if there's a captcha, assess it, otherwise check if we need a captcha
final Optional<RecaptchaClient.AssessmentResult> assessmentResult = captcha
.map(captchaToken -> recaptchaClient.verify(captchaToken, sourceHost));
assessmentResult.ifPresent(result ->
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
Tag.of("success", String.valueOf(result.valid())),
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
Tag.of(REGION_TAG_NAME, region),
Tag.of(SCORE_TAG_NAME, result.score())))
.increment());
boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, storedChallenge);
if (pushChallenge.isPresent() && !pushChallengeMatch) {
throw new WebApplicationException(Response.status(403).build());
}
final boolean requiresCaptcha = assessmentResult
.map(result -> !result.valid())
.orElseGet(() -> requiresCaptcha(number, transport, forwardedFor, sourceHost, pushChallengeMatch));
if (requiresCaptcha) {
captchaRequiredMeter.mark();
Metrics.counter(CHALLENGE_ISSUED_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
Tag.of(REGION_TAG_NAME, Util.getRegion(number))))
.increment();
if (requirement.isAutoBlock() && shouldAutoBlock(sourceHost)) {
logger.info("Auto-block: {}", sourceHost);
abusiveHostRules.setBlockedHost(sourceHost);
}
return Response.status(402).build();
}
@@ -311,6 +336,14 @@ public class AccountController {
logger.warn("Error with Twilio Verify", throwable);
return;
}
if (enrolledInVerifyExperiment && maybeVerificationSid.isEmpty() && assessmentResult.isPresent()) {
Metrics.counter(TWILIO_VERIFY_UNDELIVERED_COUNTER_NAME, Tags.of(
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
Tag.of(REGION_TAG_NAME, region),
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(SCORE_TAG_NAME, assessmentResult.get().score())))
.increment();
}
maybeVerificationSid.ifPresent(twilioVerificationSid -> {
StoredVerificationCode storedVerificationCodeWithVerificationSid = new StoredVerificationCode(
storedVerificationCode.getCode(),
@@ -642,6 +675,52 @@ public class AccountController {
accounts.clearUsername(auth.getAccount());
}
@Timed
@PUT
@Path("/username/reserved")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public ReserveUsernameResponse reserveUsername(@Auth AuthenticatedAccount auth,
@HeaderParam("X-Signal-Agent") String userAgent,
@NotNull @Valid ReserveUsernameRequest usernameRequest) throws RateLimitExceededException {
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
try {
final AccountsManager.UsernameReservation reservation = accounts.reserveUsername(
auth.getAccount(),
usernameRequest.nickname()
);
return new ReserveUsernameResponse(reservation.reservedUsername(), reservation.reservationToken());
} catch (final UsernameNotAvailableException e) {
throw new WebApplicationException(Status.CONFLICT);
}
}
@Timed
@PUT
@Path("/username/confirm")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public UsernameResponse confirmUsername(@Auth AuthenticatedAccount auth,
@HeaderParam("X-Signal-Agent") String userAgent,
@NotNull @Valid ConfirmUsernameRequest confirmRequest) throws RateLimitExceededException {
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
try {
final Account account = accounts.confirmReservedUsername(auth.getAccount(), confirmRequest.usernameToConfirm(), confirmRequest.reservationToken());
return account
.getUsername()
.map(UsernameResponse::new)
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
} catch (final UsernameReservationNotFoundException e) {
throw new WebApplicationException(Status.CONFLICT);
} catch (final UsernameNotAvailableException e) {
throw new WebApplicationException(Status.GONE);
}
}
@Timed
@PUT
@Path("/username")
@@ -652,14 +731,7 @@ public class AccountController {
@HeaderParam("X-Signal-Agent") String userAgent,
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
if (StringUtils.isNotBlank(usernameRequest.existingUsername()) &&
!UsernameGenerator.isStandardFormat(usernameRequest.existingUsername())) {
// Technically, a username may not be in the nickname#discriminator format
// if created through some out-of-band mechanism, but it is atypical.
Metrics.counter(NONSTANDARD_USERNAME_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
}
checkUsername(usernameRequest.existingUsername(), userAgent);
try {
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
@@ -688,15 +760,10 @@ public class AccountController {
throw new BadRequestException();
}
if (!UsernameGenerator.isStandardFormat(username)) {
// Technically, a username may not be in the nickname#discriminator format
// if created through some out-of-band mechanism, but it is atypical.
Metrics.counter(NONSTANDARD_USERNAME_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
}
rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor);
checkUsername(username, userAgent);
return accounts
.getByUsername(username)
.map(Account::getUuid)
@@ -760,64 +827,35 @@ public class AccountController {
}
}
private CaptchaRequirement requiresCaptcha(String number, String transport, String forwardedFor,
String sourceHost,
Optional<String> captchaToken,
Optional<StoredVerificationCode> storedVerificationCode,
Optional<String> pushChallenge,
String userAgent)
{
private boolean pushChallengeMatches(
final String number,
final Optional<String> pushChallenge,
final Optional<StoredVerificationCode> storedVerificationCode) {
final String countryCode = Util.getCountryCode(number);
final String region = Util.getRegion(number);
Optional<String> storedPushChallenge = storedVerificationCode.map(StoredVerificationCode::getPushCode);
boolean match = Optionals.zipWith(pushChallenge, storedPushChallenge, String::equals).orElse(false);
Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME,
COUNTRY_CODE_TAG_NAME, countryCode,
REGION_TAG_NAME, region,
CHALLENGE_PRESENT_TAG_NAME, Boolean.toString(pushChallenge.isPresent()),
CHALLENGE_MATCH_TAG_NAME, Boolean.toString(match))
.increment();
return match;
}
private boolean requiresCaptcha(String number, String transport, String forwardedFor, String sourceHost, boolean pushChallengeMatch) {
if (testDevices.containsKey(number)) {
return new CaptchaRequirement(false, false);
return false;
}
if (!pushChallengeMatch) {
return true;
}
final String countryCode = Util.getCountryCode(number);
final String region = Util.getRegion(number);
if (captchaToken.isPresent()) {
boolean validToken = recaptchaClient.verify(captchaToken.get(), sourceHost);
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
Tag.of("success", String.valueOf(validToken)),
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
Tag.of(REGION_TAG_NAME, region)))
.increment();
if (validToken) {
return new CaptchaRequirement(false, false);
} else {
return new CaptchaRequirement(true, false);
}
}
{
final List<Tag> tags = new ArrayList<>();
tags.add(Tag.of(COUNTRY_CODE_TAG_NAME, countryCode));
tags.add(Tag.of(REGION_TAG_NAME, region));
try {
if (pushChallenge.isPresent()) {
tags.add(Tag.of(CHALLENGE_PRESENT_TAG_NAME, "true"));
Optional<String> storedPushChallenge = storedVerificationCode.map(StoredVerificationCode::getPushCode);
if (!pushChallenge.get().equals(storedPushChallenge.orElse(null))) {
tags.add(Tag.of(CHALLENGE_MATCH_TAG_NAME, "false"));
return new CaptchaRequirement(true, false);
} else {
tags.add(Tag.of(CHALLENGE_MATCH_TAG_NAME, "true"));
}
} else {
tags.add(Tag.of(CHALLENGE_PRESENT_TAG_NAME, "false"));
return new CaptchaRequirement(true, false);
}
} finally {
Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME, tags).increment();
}
}
DynamicCaptchaConfiguration captchaConfig = dynamicConfigurationManager.getConfiguration()
.getCaptchaConfiguration();
@@ -832,7 +870,7 @@ public class AccountController {
// would be caught by country filter as well
countryFilterApplicable.mark();
}
return new CaptchaRequirement(true, false);
return true;
}
try {
@@ -840,7 +878,11 @@ public class AccountController {
} catch (RateLimitExceededException e) {
logger.info("Rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
rateLimitedHostMeter.mark();
return new CaptchaRequirement(true, true);
if (shouldAutoBlock(sourceHost)) {
logger.info("Auto-block: {}", sourceHost);
abusiveHostRules.setBlockedHost(sourceHost);
}
return true;
}
try {
@@ -848,15 +890,18 @@ public class AccountController {
} catch (RateLimitExceededException e) {
logger.info("Prefix rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
rateLimitedPrefixMeter.mark();
return new CaptchaRequirement(true, true);
if (shouldAutoBlock(sourceHost)) {
logger.info("Auto-block: {}", sourceHost);
abusiveHostRules.setBlockedHost(sourceHost);
}
return true;
}
if (countryFiltered) {
countryFilteredHostMeter.mark();
return new CaptchaRequirement(true, false);
return true;
}
return new CaptchaRequirement(false, false);
return false;
}
@Timed
@@ -866,6 +911,15 @@ public class AccountController {
accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST);
}
private void checkUsername(final String username, final String userAgent) {
if (StringUtils.isNotBlank(username) && !UsernameGenerator.isStandardFormat(username)) {
// Technically, a username may not be in the nickname#discriminator format
// if created through some out-of-band mechanism, but it is atypical.
Metrics.counter(NONSTANDARD_USERNAME_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
}
}
private boolean shouldAutoBlock(String sourceHost) {
try {
rateLimiters.getAutoBlockLimiter().validate(sourceHost);
@@ -894,22 +948,4 @@ public class AccountController {
return Hex.toStringCondensed(challenge);
}
private static class CaptchaRequirement {
private final boolean captchaRequired;
private final boolean autoBlock;
private CaptchaRequirement(boolean captchaRequired, boolean autoBlock) {
this.captchaRequired = captchaRequired;
this.autoBlock = autoBlock;
}
boolean isCaptchaRequired() {
return captchaRequired;
}
boolean isAutoBlock() {
return autoBlock;
}
}
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import org.whispersystems.textsecuregcm.util.Conversions;
import java.security.SecureRandom;
public class AttachmentControllerBase {
protected long generateAttachmentId() {
byte[] attachmentBytes = new byte[8];
new SecureRandom().nextBytes(attachmentBytes);
attachmentBytes[0] = (byte)(attachmentBytes[0] & 0x7F);
return Conversions.byteArrayToLong(attachmentBytes);
}
}

View File

@@ -1,66 +0,0 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import com.amazonaws.HttpMethod;
import com.codahale.metrics.annotation.Timed;
import io.dropwizard.auth.Auth;
import java.net.URL;
import java.util.stream.Stream;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV1;
import org.whispersystems.textsecuregcm.entities.AttachmentUri;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.UrlSigner;
@Path("/v1/attachments")
public class AttachmentControllerV1 extends AttachmentControllerBase {
@SuppressWarnings("unused")
private final Logger logger = LoggerFactory.getLogger(AttachmentControllerV1.class);
private static final String[] UNACCELERATED_REGIONS = {"+20", "+971", "+968", "+974"};
private final RateLimiters rateLimiters;
private final UrlSigner urlSigner;
public AttachmentControllerV1(RateLimiters rateLimiters, String accessKey, String accessSecret, String bucket) {
this.rateLimiters = rateLimiters;
this.urlSigner = new UrlSigner(accessKey, accessSecret, bucket);
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
public AttachmentDescriptorV1 allocateAttachment(@Auth AuthenticatedAccount auth) throws RateLimitExceededException {
rateLimiters.getAttachmentLimiter().validate(auth.getAccount().getUuid());
long attachmentId = generateAttachmentId();
URL url = urlSigner.getPreSignedUrl(attachmentId, HttpMethod.PUT,
Stream.of(UNACCELERATED_REGIONS).anyMatch(region -> auth.getAccount().getNumber().startsWith(region)));
return new AttachmentDescriptorV1(attachmentId, url.toExternalForm());
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/{attachmentId}")
public AttachmentUri redirectToAttachment(@Auth AuthenticatedAccount auth,
@PathParam("attachmentId") long attachmentId) {
return new AttachmentUri(urlSigner.getPreSignedUrl(attachmentId, HttpMethod.GET,
Stream.of(UNACCELERATED_REGIONS).anyMatch(region -> auth.getAccount().getNumber().startsWith(region))));
}
}

View File

@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import io.dropwizard.auth.Auth;
import java.security.SecureRandom;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import javax.ws.rs.GET;
@@ -19,19 +20,21 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.util.Conversions;
import org.whispersystems.textsecuregcm.util.Pair;
@Path("/v2/attachments")
public class AttachmentControllerV2 extends AttachmentControllerBase {
public class AttachmentControllerV2 {
private final PostPolicyGenerator policyGenerator;
private final PolicySigner policySigner;
private final RateLimiter rateLimiter;
private final PolicySigner policySigner;
private final RateLimiter rateLimiter;
public AttachmentControllerV2(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, String bucket) {
this.rateLimiter = rateLimiters.getAttachmentLimiter();
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
this.policySigner = new PolicySigner(accessSecret, region);
public AttachmentControllerV2(RateLimiters rateLimiters, String accessKey, String accessSecret, String region,
String bucket) {
this.rateLimiter = rateLimiters.getAttachmentLimiter();
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
this.policySigner = new PolicySigner(accessSecret, region);
}
@Timed
@@ -54,5 +57,12 @@ public class AttachmentControllerV2 extends AttachmentControllerBase {
policy.second(), signature);
}
private long generateAttachmentId() {
byte[] attachmentBytes = new byte[8];
new SecureRandom().nextBytes(attachmentBytes);
attachmentBytes[0] = (byte) (attachmentBytes[0] & 0x7F);
return Conversions.byteArrayToLong(attachmentBytes);
}
}

View File

@@ -29,7 +29,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
@Path("/v3/attachments")
public class AttachmentControllerV3 extends AttachmentControllerBase {
public class AttachmentControllerV3 {
@Nonnull
private final RateLimiter rateLimiter;

View File

@@ -22,6 +22,7 @@ import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -33,7 +34,9 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.ws.rs.BadRequestException;
@@ -72,7 +75,6 @@ import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
@@ -93,6 +95,7 @@ import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
import org.whispersystems.websocket.Stories;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/messages")
@@ -162,10 +165,11 @@ public class MessageController {
@HeaderParam("User-Agent") String userAgent,
@HeaderParam("X-Forwarded-For") String forwardedFor,
@PathParam("destination") UUID destinationUuid,
@QueryParam("story") boolean isStory,
@NotNull @Valid IncomingMessageList messages)
throws RateLimitExceededException, RateLimitChallengeException {
throws RateLimitExceededException {
if (source.isEmpty() && accessKey.isEmpty()) {
if (source.isEmpty() && accessKey.isEmpty() && !isStory) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
@@ -205,11 +209,18 @@ public class MessageController {
destination = source.map(AuthenticatedAccount::getAccount);
}
OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination);
// Stories will be checked by the client; we bypass access checks here for stories.
if (!isStory) {
OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination);
}
assert (destination.isPresent());
if (source.isPresent() && !isSyncMessage) {
checkRateLimit(source.get(), destination.get(), userAgent);
checkMessageRateLimit(source.get(), destination.get(), userAgent);
}
if (isStory) {
checkStoryRateLimit(destination.get());
}
final Set<Long> excludedDeviceIds;
@@ -239,12 +250,12 @@ public class MessageController {
if (destinationDevice.isPresent()) {
Metrics.counter(SENT_MESSAGE_COUNTER_NAME, tags).increment();
sendMessage(source, destination.get(), destinationDevice.get(), destinationUuid, messages.timestamp(), messages.online(), messages.urgent(), incomingMessage, userAgent);
sendIndividualMessage(source, destination.get(), destinationDevice.get(), destinationUuid, messages.timestamp(), messages.online(), isStory, messages.urgent(), incomingMessage, userAgent);
}
}
return Response.ok(new SendMessageResponse(
!isSyncMessage && source.isPresent() && source.get().getAccount().getEnabledDeviceCount() > 1)).build();
boolean needsSync = !isSyncMessage && source.isPresent() && source.get().getAccount().getEnabledDeviceCount() > 1;
return Response.ok(new SendMessageResponse(needsSync)).build();
} catch (NoSuchUserException e) {
throw new WebApplicationException(Response.status(404).build());
} catch (MismatchedDevicesException e) {
@@ -261,6 +272,35 @@ public class MessageController {
}
}
/**
* Build mapping of accounts to devices/registration IDs.
* <p>
* Messages that are stories will only be sent to the subset of recipients who have indicated they want to receive
* stories.
*
* @param multiRecipientMessage
* @param uuidToAccountMap
* @return
*/
private Map<Account, Set<Pair<Long, Integer>>> buildDeviceIdAndRegistrationIdMap(
MultiRecipientMessage multiRecipientMessage,
Map<UUID, Account> uuidToAccountMap
) {
Stream<Recipient> recipients = Arrays.stream(multiRecipientMessage.getRecipients());
return recipients.collect(Collectors.toMap(
recipient -> uuidToAccountMap.get(recipient.getUuid()),
recipient -> new HashSet<>(
Collections.singletonList(new Pair<>(recipient.getDeviceId(), recipient.getRegistrationId()))),
(a, b) -> {
a.addAll(b);
return a;
}
));
}
@Timed
@Path("/multi_recipient")
@PUT
@@ -268,43 +308,51 @@ public class MessageController {
@Produces(MediaType.APPLICATION_JSON)
@FilterAbusiveMessages
public Response sendMultiRecipientMessage(
@HeaderParam(OptionalAccess.UNIDENTIFIED) CombinedUnidentifiedSenderAccessKeys accessKeys,
@HeaderParam(OptionalAccess.UNIDENTIFIED) @Nullable CombinedUnidentifiedSenderAccessKeys accessKeys,
@HeaderParam("User-Agent") String userAgent,
@HeaderParam("X-Forwarded-For") String forwardedFor,
@QueryParam("online") boolean online,
@QueryParam("ts") long timestamp,
@QueryParam("story") boolean isStory,
@NotNull @Valid MultiRecipientMessage multiRecipientMessage) {
Map<UUID, Account> uuidToAccountMap = Arrays.stream(multiRecipientMessage.getRecipients())
.map(Recipient::getUuid)
.distinct()
.collect(Collectors.toUnmodifiableMap(Function.identity(), uuid -> {
Optional<Account> account = accountsManager.getByAccountIdentifier(uuid);
if (account.isEmpty()) {
throw new WebApplicationException(Status.NOT_FOUND);
}
return account.get();
}));
checkAccessKeys(accessKeys, uuidToAccountMap);
.collect(Collectors.toUnmodifiableMap(
Function.identity(),
uuid -> accountsManager
.getByAccountIdentifier(uuid)
.orElseThrow(() -> new WebApplicationException(Status.NOT_FOUND))));
final Map<Account, HashSet<Pair<Long, Integer>>> accountToDeviceIdAndRegistrationIdMap =
Arrays
.stream(multiRecipientMessage.getRecipients())
.collect(Collectors.toMap(
recipient -> uuidToAccountMap.get(recipient.getUuid()),
recipient -> new HashSet<>(
Collections.singletonList(new Pair<>(recipient.getDeviceId(), recipient.getRegistrationId()))),
(a, b) -> {
a.addAll(b);
return a;
}
));
// Stories will be checked by the client; we bypass access checks here for stories.
if (!isStory) {
checkAccessKeys(accessKeys, uuidToAccountMap);
}
final Map<Account, Set<Pair<Long, Integer>>> accountToDeviceIdAndRegistrationIdMap =
buildDeviceIdAndRegistrationIdMap(multiRecipientMessage, uuidToAccountMap);
// We might filter out all the recipients of a story (if none have enabled stories).
// In this case there is no error so we should just return 200 now.
if (isStory && accountToDeviceIdAndRegistrationIdMap.isEmpty()) {
return Response.ok(new SendMultiRecipientMessageResponse(new LinkedList<>())).build();
}
Collection<AccountMismatchedDevices> accountMismatchedDevices = new ArrayList<>();
Collection<AccountStaleDevices> accountStaleDevices = new ArrayList<>();
uuidToAccountMap.values().forEach(account -> {
final Set<Long> deviceIds = accountToDeviceIdAndRegistrationIdMap.get(account).stream().map(Pair::first)
.collect(Collectors.toSet());
if (isStory) {
checkStoryRateLimit(account);
}
Set<Long> deviceIds = accountToDeviceIdAndRegistrationIdMap
.getOrDefault(account, Collections.emptySet())
.stream()
.map(Pair::first)
.collect(Collectors.toSet());
try {
DestinationDeviceValidator.validateCompleteDeviceList(account, deviceIds, Collections.emptySet());
@@ -352,8 +400,8 @@ public class MessageController {
Device destinationDevice = destinationAccount.getDevice(recipient.getDeviceId()).orElseThrow();
sentMessageCounter.increment();
try {
sendMessage(destinationAccount, destinationDevice, timestamp, online, recipient,
multiRecipientMessage.getCommonPayload());
sendCommonPayloadMessage(destinationAccount, destinationDevice, timestamp, online, isStory,
recipient, multiRecipientMessage.getCommonPayload());
} catch (NoSuchUserException e) {
uuids404.add(destinationAccount.getUuid());
}
@@ -368,6 +416,10 @@ public class MessageController {
}
private void checkAccessKeys(CombinedUnidentifiedSenderAccessKeys accessKeys, Map<UUID, Account> uuidToAccountMap) {
// We should not have null access keys when checking access; bail out early.
if (accessKeys == null) {
throw new WebApplicationException(Status.UNAUTHORIZED);
}
AtomicBoolean throwUnauthorized = new AtomicBoolean(false);
byte[] empty = new byte[16];
final Optional<byte[]> UNRESTRICTED_UNIDENTIFIED_ACCESS_KEY = Optional.of(new byte[16]);
@@ -406,8 +458,11 @@ public class MessageController {
@GET
@Produces(MediaType.APPLICATION_JSON)
public OutgoingMessageEntityList getPendingMessages(@Auth AuthenticatedAccount auth,
@HeaderParam(Stories.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader,
@HeaderParam("User-Agent") String userAgent) {
boolean shouldReceiveStories = Stories.parseReceiveStoriesHeader(receiveStoriesHeader);
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), userAgent);
final OutgoingMessageEntityList outgoingMessages;
@@ -417,7 +472,12 @@ public class MessageController {
auth.getAuthenticatedDevice().getId(),
false);
outgoingMessages = new OutgoingMessageEntityList(messagesAndHasMore.first().stream()
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
if (!shouldReceiveStories) {
envelopes = envelopes.filter(e -> !e.getStory());
}
outgoingMessages = new OutgoingMessageEntityList(envelopes
.map(OutgoingMessageEntity::fromEnvelope)
.peek(outgoingMessageEntity -> MessageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(),
outgoingMessageEntity))
@@ -515,12 +575,13 @@ public class MessageController {
.build();
}
private void sendMessage(Optional<AuthenticatedAccount> source,
private void sendIndividualMessage(Optional<AuthenticatedAccount> source,
Account destinationAccount,
Device destinationDevice,
UUID destinationUuid,
long timestamp,
boolean online,
boolean story,
boolean urgent,
IncomingMessage incomingMessage,
String userAgentString)
@@ -533,6 +594,7 @@ public class MessageController {
source.map(AuthenticatedAccount::getAccount).orElse(null),
source.map(authenticatedAccount -> authenticatedAccount.getAuthenticatedDevice().getId()).orElse(null),
timestamp == 0 ? System.currentTimeMillis() : timestamp,
story,
urgent);
} catch (final IllegalArgumentException e) {
logger.warn("Received bad envelope type {} from {}", incomingMessage.type(), userAgentString);
@@ -546,10 +608,11 @@ public class MessageController {
}
}
private void sendMessage(Account destinationAccount,
private void sendCommonPayloadMessage(Account destinationAccount,
Device destinationDevice,
long timestamp,
boolean online,
boolean story,
Recipient recipient,
byte[] commonPayload) throws NoSuchUserException {
try {
@@ -567,6 +630,7 @@ public class MessageController {
.setTimestamp(timestamp == 0 ? serverTimestamp : timestamp)
.setServerTimestamp(serverTimestamp)
.setContent(ByteString.copyFrom(payload))
.setStory(story)
.setDestinationUuid(destinationAccount.getUuid().toString());
messageSender.sendMessage(destinationAccount, destinationDevice, messageBuilder.build(), online);
@@ -579,7 +643,14 @@ public class MessageController {
}
}
private void checkRateLimit(AuthenticatedAccount source, Account destination, String userAgent)
private void checkStoryRateLimit(Account destination) {
try {
rateLimiters.getMessagesLimiter().validate(destination.getUuid());
} catch (final RateLimitExceededException e) {
}
}
private void checkMessageRateLimit(AuthenticatedAccount source, Account destination, String userAgent)
throws RateLimitExceededException {
final String senderCountryCode = Util.getCountryCode(source.getAccount().getNumber());

View File

@@ -387,10 +387,32 @@ public class ProfileController {
private void checkFingerprintAndAdd(BatchIdentityCheckRequest.Element element,
Collection<BatchIdentityCheckResponse.Element> responseElements, MessageDigest md) {
accountsManager.getByAccountIdentifier(element.aci()).ifPresent(account -> {
final Optional<Account> maybeAccount;
final boolean usePhoneNumberIdentity;
if (element.aci() != null) {
maybeAccount = accountsManager.getByAccountIdentifier(element.aci());
usePhoneNumberIdentity = false;
} else {
final Optional<Account> maybeAciAccount = accountsManager.getByAccountIdentifier(element.uuid());
if (maybeAciAccount.isEmpty()) {
maybeAccount = accountsManager.getByPhoneNumberIdentifier(element.uuid());
usePhoneNumberIdentity = true;
} else {
maybeAccount = maybeAciAccount;
usePhoneNumberIdentity = false;
}
}
maybeAccount.ifPresent(account -> {
if (account.getIdentityKey() == null || account.getPhoneNumberIdentityKey() == null) {
return;
}
byte[] identityKeyBytes;
try {
identityKeyBytes = Base64.getDecoder().decode(account.getIdentityKey());
identityKeyBytes = Base64.getDecoder().decode(usePhoneNumberIdentity ? account.getPhoneNumberIdentityKey()
: account.getIdentityKey());
} catch (IllegalArgumentException ignored) {
return;
}
@@ -399,7 +421,7 @@ public class ProfileController {
byte[] fingerprint = Util.truncate(digest, 4);
if (!Arrays.equals(fingerprint, element.fingerprint())) {
responseElements.add(new BatchIdentityCheckResponse.Element(element.aci(), identityKeyBytes));
responseElements.add(new BatchIdentityCheckResponse.Element(element.aci(), element.uuid(), identityKeyBytes));
}
});
}

View File

@@ -14,6 +14,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -31,6 +32,9 @@ import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.signal.event.AdminEventLogger;
import org.signal.event.RemoteConfigDeleteEvent;
import org.signal.event.RemoteConfigSetEvent;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.entities.UserRemoteConfig;
import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
@@ -42,13 +46,15 @@ import org.whispersystems.textsecuregcm.util.Conversions;
public class RemoteConfigController {
private final RemoteConfigsManager remoteConfigsManager;
private final List<String> configAuthTokens;
private final Map<String, String> globalConfig;
private final AdminEventLogger adminEventLogger;
private final List<String> configAuthTokens;
private final Map<String, String> globalConfig;
private static final String GLOBAL_CONFIG_PREFIX = "global.";
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, List<String> configAuthTokens, Map<String, String> globalConfig) {
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, AdminEventLogger adminEventLogger, List<String> configAuthTokens, Map<String, String> globalConfig) {
this.remoteConfigsManager = remoteConfigsManager;
this.adminEventLogger = Objects.requireNonNull(adminEventLogger);
this.configAuthTokens = configAuthTokens;
this.globalConfig = globalConfig;
}
@@ -88,6 +94,15 @@ public class RemoteConfigController {
throw new WebApplicationException(Response.Status.FORBIDDEN);
}
adminEventLogger.logEvent(
new RemoteConfigSetEvent(
configToken,
config.getName(),
config.getPercentage(),
config.getDefaultValue(),
config.getValue(),
config.getHashKey(),
config.getUuids().stream().map(UUID::toString).collect(Collectors.toList())));
remoteConfigsManager.set(config);
}
@@ -103,6 +118,7 @@ public class RemoteConfigController {
throw new WebApplicationException(Response.Status.FORBIDDEN);
}
adminEventLogger.logEvent(new RemoteConfigDeleteEvent(configToken, name));
remoteConfigsManager.delete(name);
}

View File

@@ -15,6 +15,10 @@ public class AccountMismatchedDevices {
@JsonProperty
public final MismatchedDevices devices;
public String toString() {
return "AccountMismatchedDevices(" + uuid + ", " + devices + ")";
}
public AccountMismatchedDevices(final UUID uuid, final MismatchedDevices devices) {
this.uuid = uuid;
this.devices = devices;

View File

@@ -15,6 +15,10 @@ public class AccountStaleDevices {
@JsonProperty
public final StaleDevices devices;
public String toString() {
return "AccountStaleDevices(" + uuid + ", " + devices + ")";
}
public AccountStaleDevices(final UUID uuid, final StaleDevices devices) {
this.uuid = uuid;
this.devices = devices;

View File

@@ -1,39 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
public class AttachmentDescriptorV1 {
@JsonProperty
private long id;
@JsonProperty
private String idString;
@JsonProperty
private String location;
public AttachmentDescriptorV1(long id, String location) {
this.id = id;
this.idString = String.valueOf(id);
this.location = location;
}
public AttachmentDescriptorV1() {}
public long getId() {
return id;
}
public String getLocation() {
return location;
}
public String getIdString() {
return idString;
}
}

View File

@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.entities;
import java.util.List;
import java.util.UUID;
import javax.annotation.Nullable;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
@@ -15,11 +16,22 @@ import org.whispersystems.textsecuregcm.util.ExactlySize;
public record BatchIdentityCheckRequest(@Valid @NotNull @Size(max = 1000) List<Element> elements) {
/**
* @param aci account id
* @param fingerprint most significant 4 bytes of SHA-256 of the 33-byte identity key field (32-byte curve25519
* public key prefixed with 0x05)
* @param uuid account id or phone number id
* @param fingerprint most significant 4 bytes of SHA-256 of the 33-byte identity key field (32-byte curve25519 public
* key prefixed with 0x05)
*/
public record Element(@NotNull UUID aci, @NotNull @ExactlySize(4) byte[] fingerprint) {
public record Element(@Deprecated @Nullable UUID aci,
@Nullable UUID uuid,
@NotNull @ExactlySize(4) byte[] fingerprint) {
public Element {
if (aci == null && uuid == null) {
throw new IllegalArgumentException("aci and uuid cannot both be null");
}
if (aci != null && uuid != null) {
throw new IllegalArgumentException("aci and uuid cannot both be non-null");
}
}
}
}

View File

@@ -5,12 +5,28 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
import java.util.UUID;
import javax.annotation.Nullable;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.util.ExactlySize;
public record BatchIdentityCheckResponse(@Valid List<Element> elements) {
public record Element(@NotNull UUID aci, @NotNull @ExactlySize(33) byte[] identityKey) {}
public record Element(@Deprecated @JsonInclude(JsonInclude.Include.NON_EMPTY) @Nullable UUID aci,
@JsonInclude(JsonInclude.Include.NON_EMPTY) @Nullable UUID uuid,
@NotNull @ExactlySize(33) byte[] identityKey) {
public Element {
if (aci == null && uuid == null) {
throw new IllegalArgumentException("aci and uuid cannot both be null");
}
if (aci != null && uuid != null) {
throw new IllegalArgumentException("aci and uuid cannot both be non-null");
}
}
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.UUID;
public record ConfirmUsernameRequest(@NotBlank String usernameToConfirm, @NotNull UUID reservationToken) {}

View File

@@ -17,6 +17,7 @@ public record IncomingMessage(int type, long destinationDeviceId, int destinatio
@Nullable Account sourceAccount,
@Nullable Long sourceDeviceId,
final long timestamp,
final boolean story,
final boolean urgent) {
final MessageProtos.Envelope.Type envelopeType = MessageProtos.Envelope.Type.forNumber(type());
@@ -31,6 +32,7 @@ public record IncomingMessage(int type, long destinationDeviceId, int destinatio
.setTimestamp(timestamp)
.setServerTimestamp(System.currentTimeMillis())
.setDestinationUuid(destinationUuid.toString())
.setStory(story)
.setUrgent(urgent);
if (sourceAccount != null && sourceDeviceId != null) {

View File

@@ -21,6 +21,10 @@ public class MismatchedDevices {
@VisibleForTesting
public MismatchedDevices() {}
public String toString() {
return "MismatchedDevices(" + missingDevices + ", " + extraDevices + ")";
}
public MismatchedDevices(List<Long> missingDevices, List<Long> extraDevices) {
this.missingDevices = missingDevices;
this.extraDevices = extraDevices;

View File

@@ -13,7 +13,7 @@ import javax.annotation.Nullable;
public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullable UUID sourceUuid, int sourceDevice,
UUID destinationUuid, @Nullable UUID updatedPni, byte[] content,
long serverTimestamp, boolean urgent) {
long serverTimestamp, boolean urgent, boolean story) {
public MessageProtos.Envelope toEnvelope() {
final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()
@@ -22,6 +22,7 @@ public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullab
.setServerTimestamp(serverTimestamp())
.setDestinationUuid(destinationUuid().toString())
.setServerGuid(guid().toString())
.setStory(story)
.setUrgent(urgent);
if (sourceUuid() != null) {
@@ -51,7 +52,8 @@ public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullab
envelope.hasUpdatedPni() ? UUID.fromString(envelope.getUpdatedPni()) : null,
envelope.getContent().toByteArray(),
envelope.getServerTimestamp(),
envelope.getUrgent());
envelope.getUrgent(),
envelope.getStory());
}
@Override
@@ -63,16 +65,23 @@ public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullab
return false;
}
final OutgoingMessageEntity that = (OutgoingMessageEntity) o;
return type == that.type && timestamp == that.timestamp && sourceDevice == that.sourceDevice
&& serverTimestamp == that.serverTimestamp && guid.equals(that.guid)
&& Objects.equals(sourceUuid, that.sourceUuid) && destinationUuid.equals(that.destinationUuid)
&& Objects.equals(updatedPni, that.updatedPni) && Arrays.equals(content, that.content) && urgent == that.urgent;
return guid.equals(that.guid) &&
type == that.type &&
timestamp == that.timestamp &&
Objects.equals(sourceUuid, that.sourceUuid) &&
sourceDevice == that.sourceDevice &&
destinationUuid.equals(that.destinationUuid) &&
Objects.equals(updatedPni, that.updatedPni) &&
Arrays.equals(content, that.content) &&
serverTimestamp == that.serverTimestamp &&
urgent == that.urgent &&
story == that.story;
}
@Override
public int hashCode() {
int result = Objects.hash(guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni,
serverTimestamp, urgent);
serverTimestamp, urgent, story);
result = 31 * result + Arrays.hashCode(content);
return result;
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import org.whispersystems.textsecuregcm.util.Nickname;
import javax.validation.Valid;
public record ReserveUsernameRequest(@Valid @Nickname String nickname) {}

View File

@@ -0,0 +1,10 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import java.util.UUID;
public record ReserveUsernameResponse(String username, UUID reservationToken) {}

View File

@@ -6,6 +6,7 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import java.util.List;
import java.util.UUID;
@@ -16,6 +17,15 @@ public class SendMultiRecipientMessageResponse {
public SendMultiRecipientMessageResponse() {
}
public String toString() {
return "SendMultiRecipientMessageResponse(" + uuids404 + ")";
}
@VisibleForTesting
public List<UUID> getUUIDs404() {
return this.uuids404;
}
public SendMultiRecipientMessageResponse(final List<UUID> uuids404) {
this.uuids404 = uuids404;
}

View File

@@ -16,6 +16,10 @@ public class StaleDevices {
public StaleDevices() {}
public String toString() {
return "StaleDevices(" + staleDevices + ")";
}
public StaleDevices(List<Long> staleDevices) {
this.staleDevices = staleDevices;
}

View File

@@ -1,29 +0,0 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.limits;
import java.time.Duration;
import org.whispersystems.textsecuregcm.storage.Account;
public class RateLimitChallengeException extends Exception {
private final Account account;
private final Duration retryAfter;
public RateLimitChallengeException(final Account account, final Duration retryAfter) {
this.account = account;
this.retryAfter = retryAfter;
}
public Account getAccount() {
return account;
}
public Duration getRetryAfter() {
return retryAfter;
}
}

View File

@@ -62,7 +62,7 @@ public class RateLimitChallengeManager {
rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getUuid());
final boolean challengeSuccess = recaptchaClient.verify(captcha, mostRecentProxyIp);
final boolean challengeSuccess = recaptchaClient.verify(captcha, mostRecentProxyIp).valid();
final Tags tags = Tags.of(
Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())),

View File

@@ -33,8 +33,12 @@ public class RateLimiters {
private final RateLimiter usernameLookupLimiter;
private final RateLimiter usernameSetLimiter;
private final RateLimiter usernameReserveLimiter;
private final RateLimiter checkAccountExistenceLimiter;
private final RateLimiter storiesLimiter;
public RateLimiters(RateLimitsConfiguration config, FaultTolerantRedisCluster cacheCluster) {
this.smsDestinationLimiter = new RateLimiter(cacheCluster, "smsDestination",
config.getSmsDestination().getBucketSize(),
@@ -108,9 +112,18 @@ public class RateLimiters {
config.getUsernameSet().getBucketSize(),
config.getUsernameSet().getLeakRatePerMinute());
this.usernameReserveLimiter = new RateLimiter(cacheCluster, "usernameReserve",
config.getUsernameReserve().getBucketSize(),
config.getUsernameReserve().getLeakRatePerMinute());
this.checkAccountExistenceLimiter = new RateLimiter(cacheCluster, "checkAccountExistence",
config.getCheckAccountExistence().getBucketSize(),
config.getCheckAccountExistence().getLeakRatePerMinute());
this.storiesLimiter = new RateLimiter(cacheCluster, "stories",
config.getStories().getBucketSize(),
config.getStories().getLeakRatePerMinute());
}
public RateLimiter getAllocateDeviceLimiter() {
@@ -185,7 +198,13 @@ public class RateLimiters {
return usernameSetLimiter;
}
public RateLimiter getUsernameReserveLimiter() {
return usernameReserveLimiter;
}
public RateLimiter getCheckAccountExistenceLimiter() {
return checkAccountExistenceLimiter;
}
public RateLimiter getStoriesLimiter() { return storiesLimiter; }
}

View File

@@ -1,32 +0,0 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.mappers;
import java.util.UUID;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import org.whispersystems.textsecuregcm.entities.RateLimitChallenge;
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeException;
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeOptionManager;
public class RateLimitChallengeExceptionMapper implements ExceptionMapper<RateLimitChallengeException> {
private final RateLimitChallengeOptionManager rateLimitChallengeOptionManager;
public RateLimitChallengeExceptionMapper(final RateLimitChallengeOptionManager rateLimitChallengeOptionManager) {
this.rateLimitChallengeOptionManager = rateLimitChallengeOptionManager;
}
@Override
public Response toResponse(final RateLimitChallengeException exception) {
return Response.status(428)
.entity(new RateLimitChallenge(UUID.randomUUID().toString(),
rateLimitChallengeOptionManager.getChallengeOptions(exception.getAccount())))
.header("Retry-After", exception.getRetryAfter().toSeconds())
.build();
}
}

View File

@@ -14,6 +14,7 @@ import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceSetting
import com.google.common.annotations.VisibleForTesting;
import com.google.recaptchaenterprise.v1.Assessment;
import com.google.recaptchaenterprise.v1.Event;
import com.google.recaptchaenterprise.v1.RiskAnalysis;
import io.micrometer.core.instrument.Metrics;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -22,10 +23,13 @@ import java.util.Objects;
import javax.annotation.Nonnull;
import javax.ws.rs.BadRequestException;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
public class RecaptchaClient {
private static final Logger log = LoggerFactory.getLogger(RecaptchaClient.class);
@VisibleForTesting
static final String SEPARATOR = ".";
@@ -33,6 +37,9 @@ public class RecaptchaClient {
static final String V2_PREFIX = "signal-recaptcha-v2" + RecaptchaClient.SEPARATOR;
private static final String ASSESSMENTS_COUNTER_NAME = name(RecaptchaClient.class, "assessments");
private static final String INVALID_REASON_COUNTER_NAME = name(RecaptchaClient.class, "invalidReason");
private static final String ASSESSMENT_REASON_COUNTER_NAME = name(RecaptchaClient.class, "assessmentReason");
private final String projectPath;
private final RecaptchaEnterpriseServiceClient client;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
@@ -77,7 +84,29 @@ public class RecaptchaClient {
return parts;
}
public boolean verify(final String input, final String ip) {
/**
* A captcha assessment
*
* @param valid whether the captcha was passed
* @param score string representation of the risk level
*/
public record AssessmentResult(boolean valid, String score) {
public static AssessmentResult invalid() {
return new AssessmentResult(false, "");
}
}
/*
* recaptcha enterprise scores are from [0.0, 1.0] in increments of .1
* map to [0, 100] for easier interpretation
*/
@VisibleForTesting
static String scoreString(final float score) {
return Integer.toString((int) (score * 100));
}
public AssessmentResult verify(final String input, final String ip) {
final String[] parts = parseInputToken(input);
final String sitekey = parts[0];
@@ -101,12 +130,27 @@ public class RecaptchaClient {
"valid", String.valueOf(assessment.getTokenProperties().getValid()))
.increment();
if (assessment.getTokenProperties().getValid()) {
return assessment.getRiskAnalysis().getScore() >=
dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration().getScoreFloor().floatValue();
if (assessment.getTokenProperties().getValid()) {
final float score = assessment.getRiskAnalysis().getScore();
log.debug("assessment for {} was valid, score: {}", expectedAction, score);
for (RiskAnalysis.ClassificationReason reason : assessment.getRiskAnalysis().getReasonsList()) {
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
"action", String.valueOf(expectedAction),
"score", scoreString(score),
"reason", reason.name())
.increment();
}
return new AssessmentResult(
score >=
dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration().getScoreFloor().floatValue(),
scoreString(score));
} else {
return false;
Metrics.counter(INVALID_REASON_COUNTER_NAME,
"action", String.valueOf(expectedAction),
"reason", assessment.getTokenProperties().getInvalidReason().name())
.increment();
return AssessmentResult.invalid();
}
}
}

View File

@@ -1,46 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.s3;
import com.amazonaws.HttpMethod;
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.S3ClientOptions;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import java.net.URL;
import java.util.Date;
public class UrlSigner {
private static final long DURATION = 60 * 60 * 1000;
private final AWSCredentials credentials;
private final String bucket;
public UrlSigner(String accessKey, String accessSecret, String bucket) {
this.credentials = new BasicAWSCredentials(accessKey, accessSecret);
this.bucket = bucket;
}
public URL getPreSignedUrl(long attachmentId, HttpMethod method, boolean unaccelerated) {
AmazonS3 client = new AmazonS3Client(credentials);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucket, String.valueOf(attachmentId), method);
request.setExpiration(new Date(System.currentTimeMillis() + DURATION));
request.setContentType("application/octet-stream");
if (unaccelerated) {
client.setS3ClientOptions(S3ClientOptions.builder().setPathStyleAccess(true).build());
} else {
client.setS3ClientOptions(S3ClientOptions.builder().setAccelerateModeEnabled(true).build());
}
return client.generatePresignedUrl(request);
}
}

View File

@@ -60,6 +60,8 @@ public class TwilioSmsSender {
static final String SERVICE_NAME_TAG = "service";
static final String STATUS_CODE_TAG_NAME = "statusCode";
static final String ERROR_CODE_TAG_NAME = "errorCode";
static final String COUNTRY_CODE_TAG_NAME = "countryCode";
static final String REGION_TAG_NAME = "region";
private final String accountId;
private final String accountToken;
@@ -213,14 +215,19 @@ public class TwilioSmsSender {
return true;
} else if (response != null && response.isFailure()) {
String countryCode = Util.getCountryCode(destination);
String region = Util.getRegion(destination);
Metrics.counter(FAILED_REQUEST_COUNTER_NAME,
SERVICE_NAME_TAG, "classic",
STATUS_CODE_TAG_NAME, String.valueOf(response.failureResponse.status),
ERROR_CODE_TAG_NAME, String.valueOf(response.failureResponse.code)).increment();
ERROR_CODE_TAG_NAME, String.valueOf(response.failureResponse.code),
COUNTRY_CODE_TAG_NAME, countryCode,
REGION_TAG_NAME, region).increment();
logger.info("Failed with code={}, country={}",
response.failureResponse.code,
Util.getCountryCode(destination));
countryCode);
return false;
} else if (throwable != null) {

View File

@@ -157,14 +157,19 @@ class TwilioVerifySender {
}
if (twilioVerifyResponse.isFailure()) {
String countryCode = Util.getCountryCode(destination);
String region = Util.getRegion(destination);
Metrics.counter(TwilioSmsSender.FAILED_REQUEST_COUNTER_NAME,
TwilioSmsSender.SERVICE_NAME_TAG, "verify",
TwilioSmsSender.STATUS_CODE_TAG_NAME, String.valueOf(twilioVerifyResponse.failureResponse.status),
TwilioSmsSender.ERROR_CODE_TAG_NAME, String.valueOf(twilioVerifyResponse.failureResponse.code)).increment();
TwilioSmsSender.ERROR_CODE_TAG_NAME, String.valueOf(twilioVerifyResponse.failureResponse.code),
TwilioSmsSender.COUNTRY_CODE_TAG_NAME, countryCode,
TwilioSmsSender.REGION_TAG_NAME, region).increment();
logger.info("Failed with code={}, country={}",
twilioVerifyResponse.failureResponse.code,
Util.getCountryCode(destination));
countryCode);
return Optional.empty();
}

View File

@@ -42,6 +42,10 @@ public class Account {
@Nullable
private String username;
@JsonProperty
@Nullable
private byte[] reservedUsernameHash;
@JsonProperty
private List<Device> devices = new ArrayList<>();
@@ -133,6 +137,18 @@ public class Account {
this.username = username;
}
public Optional<byte[]> getReservedUsernameHash() {
requireNotStale();
return Optional.ofNullable(reservedUsernameHash);
}
public void setReservedUsernameHash(final byte[] reservedUsernameHash) {
requireNotStale();
this.reservedUsernameHash = reservedUsernameHash;
}
public void addDevice(Device device) {
requireNotStale();

View File

@@ -13,6 +13,10 @@ import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
@@ -70,7 +74,10 @@ public class Accounts extends AbstractDynamoDbStore {
static final String ATTR_USERNAME = "N";
// unidentified access key; byte[] or null
static final String ATTR_UAK = "UAK";
// time to live; number
static final String ATTR_TTL = "TTL";
private final Clock clock;
private final DynamoDbClient client;
private final DynamoDbAsyncClient asyncClient;
@@ -81,9 +88,12 @@ public class Accounts extends AbstractDynamoDbStore {
private final int scanPageSize;
private static final byte RESERVED_USERNAME_HASH_VERSION = 1;
private static final Timer CREATE_TIMER = Metrics.timer(name(Accounts.class, "create"));
private static final Timer CHANGE_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "changeNumber"));
private static final Timer SET_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "setUsername"));
private static final Timer RESERVE_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "reserveUsername"));
private static final Timer CLEAR_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "clearUsername"));
private static final Timer UPDATE_TIMER = Metrics.timer(name(Accounts.class, "update"));
private static final Timer GET_BY_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "getByNumber"));
@@ -96,13 +106,16 @@ public class Accounts extends AbstractDynamoDbStore {
private static final Logger log = LoggerFactory.getLogger(Accounts.class);
public Accounts(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
@VisibleForTesting
public Accounts(
final Clock clock,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
DynamoDbClient client, DynamoDbAsyncClient asyncClient,
String accountsTableName, String phoneNumberConstraintTableName,
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
final int scanPageSize) {
super(client);
this.clock = clock;
this.client = client;
this.asyncClient = asyncClient;
this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;
@@ -112,6 +125,16 @@ public class Accounts extends AbstractDynamoDbStore {
this.scanPageSize = scanPageSize;
}
public Accounts(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
DynamoDbClient client, DynamoDbAsyncClient asyncClient,
String accountsTableName, String phoneNumberConstraintTableName,
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
final int scanPageSize) {
this(Clock.systemUTC(), dynamicConfigurationManager, client, asyncClient, accountsTableName,
phoneNumberConstraintTableName, phoneNumberIdentifierConstraintTableName, usernamesConstraintTableName,
scanPageSize);
}
public boolean create(Account account) {
return CREATE_TIMER.record(() -> {
@@ -331,33 +354,150 @@ public class Accounts extends AbstractDynamoDbStore {
});
}
public static byte[] reservedUsernameHash(final UUID accountId, final String reservedUsername) {
final MessageDigest sha256;
try {
sha256 = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
final ByteBuffer byteBuffer = ByteBuffer.allocate(32 + 1);
sha256.update(reservedUsername.getBytes(StandardCharsets.UTF_8));
sha256.update(UUIDUtil.toBytes(accountId));
byteBuffer.put(RESERVED_USERNAME_HASH_VERSION);
byteBuffer.put(sha256.digest());
return byteBuffer.array();
}
/**
* Set the account username
* Reserve a username under a token
*
* @param account to update
* @param username believed to be available
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
* @return a reservation token that must be provided when {@link #confirmUsername(Account, String, UUID)} is called
*/
public void setUsername(final Account account, final String username)
throws ContestedOptimisticLockException {
public UUID reserveUsername(
final Account account,
final String reservedUsername,
final Duration ttl) {
final long startNanos = System.nanoTime();
final Optional<String> maybeOriginalUsername = account.getUsername();
account.setUsername(username);
// if there is an existing old reservation it will be cleaned up via ttl
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
account.setReservedUsernameHash(reservedUsernameHash(account.getUuid(), reservedUsername));
boolean succeeded = false;
long expirationTime = clock.instant().plus(ttl).getEpochSecond();
final UUID reservationToken = UUID.randomUUID();
try {
final List<TransactWriteItem> writeItems = new ArrayList<>();
writeItems.add(TransactWriteItem.builder()
.put(Put.builder()
.tableName(usernamesConstraintTableName)
.item(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(reservationToken),
ATTR_USERNAME, AttributeValues.fromString(reservedUsername),
ATTR_TTL, AttributeValues.fromLong(expirationTime)))
.conditionExpression("attribute_not_exists(#username) OR (#ttl < :now)")
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME, "#ttl", ATTR_TTL))
.expressionAttributeValues(Map.of(":now", AttributeValues.fromLong(clock.instant().getEpochSecond())))
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
.build())
.build());
writeItems.add(
TransactWriteItem.builder()
.update(Update.builder()
.tableName(accountsTableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
.updateExpression("SET #data = :data ADD #version :version_increment")
.conditionExpression("#version = :version")
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA, "#version", ATTR_VERSION))
.expressionAttributeValues(Map.of(
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
":version", AttributeValues.fromInt(account.getVersion()),
":version_increment", AttributeValues.fromInt(1)))
.build())
.build());
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
.transactItems(writeItems)
.build();
client.transactWriteItems(request);
account.setVersion(account.getVersion() + 1);
succeeded = true;
} catch (final JsonProcessingException e) {
throw new IllegalArgumentException(e);
} catch (final TransactionCanceledException e) {
if (e.cancellationReasons().stream().map(CancellationReason::code).anyMatch("ConditionalCheckFailed"::equals)) {
throw new ContestedOptimisticLockException();
}
throw e;
} finally {
if (!succeeded) {
account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));
}
RESERVE_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
}
return reservationToken;
}
/**
* Confirm (set) a previously reserved username
*
* @param account to update
* @param username believed to be available
* @param reservationToken a token returned by the call to {@link #reserveUsername(Account, String, Duration)},
* only required if setting a reserved username
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
*/
public void confirmUsername(final Account account, final String username, final UUID reservationToken)
throws ContestedOptimisticLockException {
setUsername(account, username, Optional.of(reservationToken));
}
/**
* Set the account username
*
* @param account to update
* @param username believed to be available
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
*/
public void setUsername(final Account account, final String username) throws ContestedOptimisticLockException {
setUsername(account, username, Optional.empty());
}
private void setUsername(final Account account, final String username, final Optional<UUID> reservationToken)
throws ContestedOptimisticLockException {
final long startNanos = System.nanoTime();
final Optional<String> maybeOriginalUsername = account.getUsername();
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
account.setUsername(username);
account.setReservedUsernameHash(null);
boolean succeeded = false;
try {
final List<TransactWriteItem> writeItems = new ArrayList<>();
// add the username to the constraint table, wiping out the ttl if we had already reserved the name
writeItems.add(TransactWriteItem.builder()
.put(Put.builder()
.tableName(usernamesConstraintTableName)
.item(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
ATTR_USERNAME, AttributeValues.fromString(username)))
.conditionExpression("attribute_not_exists(#username)")
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME))
// it's not in the constraint table OR it's expired OR it was reserved by us
.conditionExpression("attribute_not_exists(#username) OR #ttl < :now OR #aci = :reservation ")
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME, "#ttl", ATTR_TTL, "#aci", KEY_ACCOUNT_UUID))
.expressionAttributeValues(Map.of(
":now", AttributeValues.fromLong(clock.instant().getEpochSecond()),
":reservation", AttributeValues.fromUUID(reservationToken.orElseGet(UUID::randomUUID))))
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
.build())
.build());
@@ -405,6 +545,7 @@ public class Accounts extends AbstractDynamoDbStore {
} finally {
if (!succeeded) {
account.setUsername(maybeOriginalUsername.orElse(null));
account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));
}
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
}
@@ -553,11 +694,29 @@ public class Accounts extends AbstractDynamoDbStore {
}
public boolean usernameAvailable(final String username) {
return usernameAvailable(Optional.empty(), username);
}
public boolean usernameAvailable(final Optional<UUID> reservationToken, final String username) {
final GetItemResponse response = client.getItem(GetItemRequest.builder()
.tableName(usernamesConstraintTableName)
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
.build());
return !response.hasItem();
if (!response.hasItem()) {
// username is free
return true;
}
final Map<String, AttributeValue> item = response.item();
if (AttributeValues.getLong(item, ATTR_TTL, Long.MAX_VALUE) < clock.instant().getEpochSecond()) {
// username was reserved, but has expired
return true;
}
// username is reserved by us
return reservationToken
.map(AttributeValues.getUUID(item, KEY_ACCOUNT_UUID, new UUID(0, 0))::equals)
.orElse(false);
}
public Optional<Account> getByE164(String number) {
@@ -583,7 +742,10 @@ public class Accounts extends AbstractDynamoDbStore {
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
.build());
return Optional.ofNullable(response.item())
// ignore items with a ttl (reservations)
.filter(item -> !item.containsKey(ATTR_TTL))
.map(item -> item.get(KEY_ACCOUNT_UUID))
.map(this::accountByUuid)
.map(Accounts::fromItem);

View File

@@ -13,6 +13,7 @@ import com.codahale.metrics.Timer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import io.lettuce.core.RedisException;
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
import io.micrometer.core.instrument.Metrics;
@@ -20,6 +21,7 @@ import io.micrometer.core.instrument.Tags;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -85,7 +87,7 @@ public class AccountsManager {
private final DirectoryQueue directoryQueue;
private final Keys keys;
private final MessagesManager messagesManager;
private final ReservedUsernames reservedUsernames;
private final ProhibitedUsernames prohibitedUsernames;
private final ProfilesManager profilesManager;
private final StoredVerificationCodeManager pendingAccounts;
private final SecureStorageClient secureStorageClient;
@@ -127,7 +129,7 @@ public class AccountsManager {
final DirectoryQueue directoryQueue,
final Keys keys,
final MessagesManager messagesManager,
final ReservedUsernames reservedUsernames,
final ProhibitedUsernames prohibitedUsernames,
final ProfilesManager profilesManager,
final StoredVerificationCodeManager pendingAccounts,
final SecureStorageClient secureStorageClient,
@@ -148,7 +150,7 @@ public class AccountsManager {
this.secureStorageClient = secureStorageClient;
this.secureBackupClient = secureBackupClient;
this.clientPresenceManager = clientPresenceManager;
this.reservedUsernames = reservedUsernames;
this.prohibitedUsernames = prohibitedUsernames;
this.usernameGenerator = usernameGenerator;
this.experimentEnrollmentManager = experimentEnrollmentManager;
this.clock = Objects.requireNonNull(clock);
@@ -321,12 +323,112 @@ public class AccountsManager {
return updatedAccount.get();
}
public record UsernameReservation(Account account, String reservedUsername, UUID reservationToken){}
/**
* Generate a username from a nickname, and reserve it so no other accounts may take it.
*
* The reserved username can later be set with {@link #confirmReservedUsername(Account, String, UUID)}. The reservation
* will eventually expire, after which point confirmReservedUsername may fail if another account has taken the
* username.
*
* @param account the account to update
* @param requestedNickname the nickname to reserve a username for
* @return the reserved username and an updated Account object
* @throws UsernameNotAvailableException no username is available for the requested nickname
*/
public UsernameReservation reserveUsername(final Account account, final String requestedNickname) throws UsernameNotAvailableException {
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
throw new UsernameNotAvailableException();
}
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
throw new UsernameNotAvailableException();
}
redisDelete(account);
class Reserver implements AccountPersister {
UUID reservationToken;
String reservedUsername;
@Override
public void persistAccount(final Account account) throws UsernameNotAvailableException {
// In the future, this may also check for any forbidden discriminators
reservedUsername = usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable);
reservationToken = accounts.reserveUsername(
account,
reservedUsername,
usernameGenerator.getReservationTtl());
}
}
final Reserver reserver = new Reserver();
final Account updatedAccount = failableUpdateWithRetries(
account,
a -> true,
reserver,
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
return new UsernameReservation(updatedAccount, reserver.reservedUsername, reserver.reservationToken);
}
/**
* Set a username previously reserved with {@link #reserveUsername(Account, String)}
*
* @param account the account to update
* @param reservedUsername the previously reserved username
* @param reservationToken the UUID returned from the reservation
* @return the updated account with the username field set
* @throws UsernameNotAvailableException if the reserved username has been taken (because the reservation expired)
* @throws UsernameReservationNotFoundException if `reservedUsername` was not reserved in the account
*/
public Account confirmReservedUsername(final Account account, final String reservedUsername, final UUID reservationToken) throws UsernameNotAvailableException, UsernameReservationNotFoundException {
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
throw new UsernameNotAvailableException();
}
if (account.getUsername().map(reservedUsername::equals).orElse(false)) {
// the client likely already succeeded and is retrying
return account;
}
final byte[] newHash = Accounts.reservedUsernameHash(account.getUuid(), reservedUsername);
if (!account.getReservedUsernameHash().map(oldHash -> Arrays.equals(oldHash, newHash)).orElse(false)) {
// no such reservation existed, either there was no previous call to reserveUsername
// or the reservation changed
throw new UsernameReservationNotFoundException();
}
redisDelete(account);
return failableUpdateWithRetries(
account,
a -> true,
a -> {
// though we know this username was reserved, the reservation could have lapsed
if (!accounts.usernameAvailable(Optional.of(reservationToken), reservedUsername)) {
throw new UsernameNotAvailableException();
}
accounts.confirmUsername(a, reservedUsername, reservationToken);
},
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
}
/**
* Sets a username generated from `requestedNickname` with no prior reservation
*
* @param account the account to update
* @param requestedNickname the nickname to generate a username from
* @param expectedOldUsername the expected existing username of the account (for replay detection)
* @return the updated account with the username field set
* @throws UsernameNotAvailableException if no free username could be set for `requestedNickname`
*/
public Account setUsername(final Account account, final String requestedNickname, final @Nullable String expectedOldUsername) throws UsernameNotAvailableException {
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
throw new UsernameNotAvailableException();
}
if (reservedUsernames.isReserved(requestedNickname, account.getUuid())) {
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
throw new UsernameNotAvailableException();
}
@@ -345,7 +447,9 @@ public class AccountsManager {
account,
a -> true,
// In the future, this may also check for any forbidden discriminators
a -> accounts.setUsername(a, usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable)),
a -> accounts.setUsername(
a,
usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable)),
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
}
@@ -391,6 +495,16 @@ public class AccountsManager {
});
}
public Account updateDeviceAuthentication(final Account account, final Device device, final AuthenticationCredentials credentials) {
Preconditions.checkArgument(credentials.getVersion() == AuthenticationCredentials.CURRENT_VERSION);
return updateDevice(account, device.getId(), new Consumer<Device>() {
@Override
public void accept(final Device device) {
device.setAuthenticationCredentials(credentials);
}
});
}
/**
* @param account account to update
* @param updater must return {@code true} if the account was actually updated
@@ -528,7 +642,6 @@ public class AccountsManager {
public Optional<Account> getByUsername(final String username) {
try (final Timer.Context ignored = getByUsernameTimer.time()) {
Optional<Account> account = redisGetByUsername(username);
if (account.isEmpty()) {
account = accounts.getByUsername(username);
account.ifPresent(this::redisSet);

View File

@@ -26,7 +26,7 @@ import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
public class ReservedUsernames {
public class ProhibitedUsernames {
private final DynamoDbClient dynamoDbClient;
private final String tableName;
@@ -44,17 +44,17 @@ public class ReservedUsernames {
static final String KEY_PATTERN = "P";
private static final String ATTR_RESERVED_FOR_UUID = "U";
private static final Timer IS_RESERVED_TIMER = Metrics.timer(name(ReservedUsernames.class, "isReserved"));
private static final Timer IS_PROHIBITED_TIMER = Metrics.timer(name(ProhibitedUsernames.class, "isProhibited"));
private static final Logger log = LoggerFactory.getLogger(ReservedUsernames.class);
private static final Logger log = LoggerFactory.getLogger(ProhibitedUsernames.class);
public ReservedUsernames(final DynamoDbClient dynamoDbClient, final String tableName) {
public ProhibitedUsernames(final DynamoDbClient dynamoDbClient, final String tableName) {
this.dynamoDbClient = dynamoDbClient;
this.tableName = tableName;
}
public boolean isReserved(final String nickname, final UUID accountIdentifier) {
return IS_RESERVED_TIMER.record(() -> {
public boolean isProhibited(final String nickname, final UUID accountIdentifier) {
return IS_PROHIBITED_TIMER.record(() -> {
final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder()
.tableName(tableName)
.build());
@@ -80,7 +80,13 @@ public class ReservedUsernames {
});
}
public void reserveUsername(final String pattern, final UUID reservedFor) {
/**
* Prohibits username except for all accounts except `reservedFor`
*
* @param pattern pattern to prohibit
* @param reservedFor an account that is allowed to use names in the pattern
*/
public void prohibitUsername(final String pattern, final UUID reservedFor) {
dynamoDbClient.putItem(PutItemRequest.builder()
.tableName(tableName)
.item(Map.of(

View File

@@ -0,0 +1,10 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
public class UsernameReservationNotFoundException extends Exception {
}

View File

@@ -0,0 +1,21 @@
package org.whispersystems.textsecuregcm.util;
import java.util.Optional;
import java.util.function.BiFunction;
public class Optionals {
private Optionals() {}
/**
* Apply a function to two optional arguments, returning empty if either argument is empty
*
* @param optionalT Optional of type T
* @param optionalU Optional of type U
* @param fun Function of T and U that returns R
* @return The function applied to the values of optionalT and optionalU, or empty
*/
public static <T, U, R> Optional<R> zipWith(Optional<T> optionalT, Optional<U> optionalU, BiFunction<T, U, R> fun) {
return optionalT.flatMap(t -> optionalU.map(u -> fun.apply(t, u)));
}
}

View File

@@ -13,6 +13,7 @@ import io.micrometer.core.instrument.Metrics;
import org.apache.commons.lang3.StringUtils;
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Predicate;
import java.util.regex.Pattern;
@@ -33,7 +34,7 @@ public class UsernameGenerator {
* Usernames typically consist of a nickname and an integer discriminator
*/
public static final Pattern NICKNAME_PATTERN = Pattern.compile("^[_a-z][_a-z0-9]{2,31}$");
public static final String SEPARATOR = "#";
public static final String SEPARATOR = ".";
private static final Counter USERNAME_NOT_AVAILABLE_COUNTER = Metrics.counter(name(UsernameGenerator.class, "usernameNotAvailable"));
private static final DistributionSummary DISCRIMINATOR_ATTEMPT_COUNTER = Metrics.summary(name(UsernameGenerator.class, "discriminatorAttempts"));
@@ -41,16 +42,21 @@ public class UsernameGenerator {
private final int initialWidth;
private final int discriminatorMaxWidth;
private final int attemptsPerWidth;
private final Duration reservationTtl;
public UsernameGenerator(UsernameConfiguration configuration) {
this(configuration.getDiscriminatorInitialWidth(), configuration.getDiscriminatorMaxWidth(), configuration.getAttemptsPerWidth());
this(configuration.getDiscriminatorInitialWidth(),
configuration.getDiscriminatorMaxWidth(),
configuration.getAttemptsPerWidth(),
configuration.getReservationTtl());
}
@VisibleForTesting
public UsernameGenerator(int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth) {
public UsernameGenerator(int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth, final Duration reservationTtl) {
this.initialWidth = initialWidth;
this.discriminatorMaxWidth = discriminatorMaxWidth;
this.attemptsPerWidth = attemptsPerWidth;
this.reservationTtl = reservationTtl;
}
/**
@@ -106,7 +112,11 @@ public class UsernameGenerator {
throw new IllegalArgumentException("Invalid nickname " + nickname);
}
// zero pad discriminators less than the discriminator initial width
return String.format("%s#%0" + initialWidth + "d", nickname, discriminator);
return String.format("%s%s%0" + initialWidth + "d", nickname, SEPARATOR, discriminator);
}
public Duration getReservationTtl() {
return reservationTtl;
}
public static boolean isValidNickname(final String nickname) {

View File

@@ -8,7 +8,6 @@ import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat;
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
import org.apache.commons.lang3.StringUtils;
import java.time.Clock;
import java.time.Duration;
import java.time.temporal.ChronoField;
@@ -22,7 +21,7 @@ import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
public class Util {
@@ -81,7 +80,6 @@ public class Util {
else return "0";
}
@Nullable
public static String getRegion(final String number) {
try {
final PhoneNumber phoneNumber = PHONE_NUMBER_UTIL.parse(number, null);

View File

@@ -15,6 +15,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.eclipse.jetty.websocket.api.UpgradeResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;

View File

@@ -332,7 +332,16 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
final Envelope envelope = messages.get(i);
final UUID messageGuid = UUID.fromString(envelope.getServerGuid());
if (envelope.getSerializedSize() > MAX_DESKTOP_MESSAGE_SIZE && isDesktopClient) {
final boolean discard;
if (isDesktopClient && envelope.getSerializedSize() > MAX_DESKTOP_MESSAGE_SIZE) {
discard = true;
} else if (envelope.getStory() && !client.shouldDeliverStories()) {
discard = true;
} else {
discard = false;
}
if (discard) {
messagesManager.delete(auth.getAccount().getUuid(), device.getId(), messageGuid, envelope.getServerTimestamp());
discardedMessagesMeter.mark();

View File

@@ -47,7 +47,7 @@ import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
@@ -152,7 +152,7 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfiles().getTableName());
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
Keys keys = new Keys(dynamoDbClient,
configuration.getDynamoDbTables().getKeys().getTableName());
@@ -194,7 +194,7 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
experimentEnrollmentManager, Clock.systemUTC());

View File

@@ -50,7 +50,7 @@ import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
@@ -154,7 +154,7 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfiles().getTableName());
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
Keys keys = new Keys(dynamoDbClient,
configuration.getDynamoDbTables().getKeys().getTableName());
@@ -196,7 +196,7 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
experimentEnrollmentManager, clock);

View File

@@ -10,7 +10,7 @@ import io.dropwizard.setup.Bootstrap;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import java.util.UUID;
@@ -44,7 +44,7 @@ public class ReserveUsernameCommand extends ConfiguredCommand<WhisperServerConfi
final DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client(config.getDynamoDbClientConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
final ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
final ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
config.getDynamoDbTables().getReservedUsernames().getTableName());
final String pattern = namespace.getString("pattern").trim();
@@ -57,7 +57,7 @@ public class ReserveUsernameCommand extends ConfiguredCommand<WhisperServerConfi
final UUID aci = UUID.fromString(namespace.getString("uuid").trim());
reservedUsernames.reserveUsername(pattern, aci);
prohibitedUsernames.prohibitUsername(pattern, aci);
System.out.format("Reserved %s for account %s\n", pattern, aci);
}

View File

@@ -48,7 +48,7 @@ import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
@@ -157,7 +157,7 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfiles().getTableName());
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
Keys keys = new Keys(dynamoDbClient,
configuration.getDynamoDbTables().getKeys().getTableName());
@@ -197,7 +197,7 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
experimentEnrollmentManager, clock);

View File

@@ -42,7 +42,8 @@ message Envelope {
optional string destination_uuid = 13;
optional bool urgent = 14 [default=true];
optional string updated_pni = 15;
// next: 16
optional bool story = 16; // indicates that the content is a story.
// next: 17
}
message ProvisioningUuid {

View File

@@ -14,6 +14,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -30,6 +31,7 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
@@ -164,6 +166,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password), true);
@@ -171,6 +174,7 @@ class BaseAccountAuthenticatorTest {
assertThat(maybeAuthenticatedAccount).isPresent();
assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid);
assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(device);
verify(accountsManager, never()).updateDeviceAuthentication(any(), any(), any());;
}
@Test
@@ -192,6 +196,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid + "." + deviceId, password), true);
@@ -199,6 +204,7 @@ class BaseAccountAuthenticatorTest {
assertThat(maybeAuthenticatedAccount).isPresent();
assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid);
assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(device);
verify(accountsManager, never()).updateDeviceAuthentication(any(), any(), any());
}
@ParameterizedTest
@@ -221,6 +227,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(false);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password), enabledRequired);
@@ -234,6 +241,37 @@ class BaseAccountAuthenticatorTest {
}
}
@Test
void testAuthenticateV1() {
final UUID uuid = UUID.randomUUID();
final long deviceId = 1;
final String password = "12345";
final Account account = mock(Account.class);
final Device device = mock(Device.class);
final AuthenticationCredentials credentials = mock(AuthenticationCredentials.class);
when(clock.instant()).thenReturn(Instant.now());
when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account));
when(account.getUuid()).thenReturn(uuid);
when(account.getDevice(deviceId)).thenReturn(Optional.of(device));
when(account.isEnabled()).thenReturn(true);
when(device.getId()).thenReturn(deviceId);
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.Version.V1);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password), true);
assertThat(maybeAuthenticatedAccount).isPresent();
assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid);
assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(device);
verify(accountsManager, times(1)).updateDeviceAuthentication(
any(), // this won't be 'account', because it'll already be updated by updateDeviceLastSeen
eq(device), any());
}
@Test
void testAuthenticateAccountNotFound() {
assertThat(baseAccountAuthenticator.authenticate(new BasicCredentials(UUID.randomUUID().toString(), "password"), true))
@@ -259,6 +297,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid + "." + (deviceId + 1), password), true);
@@ -286,6 +325,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final String incorrectPassword = password + "incorrect";

View File

@@ -54,6 +54,9 @@ class DynamicConfigurationTest {
uuidsOnly:
enrolledUuids:
- 71618739-114c-4b1f-bb0d-6478a44eb600
uuids-with-dash:
enrolledUuids:
- 71618739-114c-4b1f-bb0d-6478ffffffff
""");
final DynamicConfiguration config =
@@ -77,6 +80,11 @@ class DynamicConfigurationTest {
assertEquals(0, config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrollmentPercentage());
assertEquals(Set.of(UUID.fromString("71618739-114c-4b1f-bb0d-6478a44eb600")),
config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrolledUuids());
assertTrue(config.getExperimentEnrollmentConfiguration("uuids-with-dash").isPresent());
assertEquals(0, config.getExperimentEnrollmentConfiguration("uuids-with-dash").get().getEnrollmentPercentage());
assertEquals(Set.of(UUID.fromString("71618739-114c-4b1f-bb0d-6478ffffffff")),
config.getExperimentEnrollmentConfiguration("uuids-with-dash").get().getEnrolledUuids());
}
}

View File

@@ -32,15 +32,21 @@ import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.Invocation;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
@@ -62,11 +68,13 @@ import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider;
import org.whispersystems.textsecuregcm.push.MessageSender;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
@@ -80,6 +88,7 @@ import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.websocket.Stories;
@ExtendWith(DropwizardExtensionsSupport.class)
class MessageControllerTest {
@@ -87,10 +96,20 @@ class MessageControllerTest {
private static final String SINGLE_DEVICE_RECIPIENT = "+14151111111";
private static final UUID SINGLE_DEVICE_UUID = UUID.randomUUID();
private static final UUID SINGLE_DEVICE_PNI = UUID.randomUUID();
private static final int SINGLE_DEVICE_ID1 = 1;
private static final int SINGLE_DEVICE_REG_ID1 = 111;
private static final String MULTI_DEVICE_RECIPIENT = "+14152222222";
private static final UUID MULTI_DEVICE_UUID = UUID.randomUUID();
private static final UUID MULTI_DEVICE_PNI = UUID.randomUUID();
private static final int MULTI_DEVICE_ID1 = 1;
private static final int MULTI_DEVICE_ID2 = 2;
private static final int MULTI_DEVICE_ID3 = 3;
private static final int MULTI_DEVICE_REG_ID1 = 222;
private static final int MULTI_DEVICE_REG_ID2 = 333;
private static final int MULTI_DEVICE_REG_ID3 = 444;
private static final byte[] UNIDENTIFIED_ACCESS_BYTES = "0123456789abcdef".getBytes();
private static final String INTERNATIONAL_RECIPIENT = "+61123456789";
private static final UUID INTERNATIONAL_UUID = UUID.randomUUID();
@@ -116,6 +135,7 @@ class MessageControllerTest {
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
.addProvider(RateLimitExceededExceptionMapper.class)
.addProvider(MultiRecipientMessageProvider.class)
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager,
@@ -125,18 +145,18 @@ class MessageControllerTest {
@BeforeEach
void setup() {
final List<Device> singleDeviceList = List.of(
generateTestDevice(1, 111, 1111, new SignedPreKey(333, "baz", "boop"), System.currentTimeMillis(), System.currentTimeMillis())
generateTestDevice(SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1, 1111, new SignedPreKey(333, "baz", "boop"), System.currentTimeMillis(), System.currentTimeMillis())
);
final List<Device> multiDeviceList = List.of(
generateTestDevice(1, 222, 2222, new SignedPreKey(111, "foo", "bar"), System.currentTimeMillis(), System.currentTimeMillis()),
generateTestDevice(2, 333, 3333, new SignedPreKey(222, "oof", "rab"), System.currentTimeMillis(), System.currentTimeMillis()),
generateTestDevice(3, 444, 4444, null, System.currentTimeMillis(), System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31))
generateTestDevice(MULTI_DEVICE_ID1, MULTI_DEVICE_REG_ID1, 2222, new SignedPreKey(111, "foo", "bar"), System.currentTimeMillis(), System.currentTimeMillis()),
generateTestDevice(MULTI_DEVICE_ID2, MULTI_DEVICE_REG_ID2, 3333, new SignedPreKey(222, "oof", "rab"), System.currentTimeMillis(), System.currentTimeMillis()),
generateTestDevice(MULTI_DEVICE_ID3, MULTI_DEVICE_REG_ID3, 4444, null, System.currentTimeMillis(), System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31))
);
Account singleDeviceAccount = AccountsHelper.generateTestAccount(SINGLE_DEVICE_RECIPIENT, SINGLE_DEVICE_UUID, SINGLE_DEVICE_PNI, singleDeviceList, "1234".getBytes());
Account multiDeviceAccount = AccountsHelper.generateTestAccount(MULTI_DEVICE_RECIPIENT, MULTI_DEVICE_UUID, MULTI_DEVICE_PNI, multiDeviceList, "1234".getBytes());
internationalAccount = AccountsHelper.generateTestAccount(INTERNATIONAL_RECIPIENT, INTERNATIONAL_UUID, UUID.randomUUID(), singleDeviceList, "1234".getBytes());
Account singleDeviceAccount = AccountsHelper.generateTestAccount(SINGLE_DEVICE_RECIPIENT, SINGLE_DEVICE_UUID, SINGLE_DEVICE_PNI, singleDeviceList, UNIDENTIFIED_ACCESS_BYTES);
Account multiDeviceAccount = AccountsHelper.generateTestAccount(MULTI_DEVICE_RECIPIENT, MULTI_DEVICE_UUID, MULTI_DEVICE_PNI, multiDeviceList, UNIDENTIFIED_ACCESS_BYTES);
internationalAccount = AccountsHelper.generateTestAccount(INTERNATIONAL_RECIPIENT, INTERNATIONAL_UUID, UUID.randomUUID(), singleDeviceList, UNIDENTIFIED_ACCESS_BYTES);
when(accountsManager.getByAccountIdentifier(eq(SINGLE_DEVICE_UUID))).thenReturn(Optional.of(singleDeviceAccount));
when(accountsManager.getByPhoneNumberIdentifier(SINGLE_DEVICE_PNI)).thenReturn(Optional.of(singleDeviceAccount));
@@ -171,7 +191,8 @@ class MessageControllerTest {
rateLimiters,
rateLimiter,
pushNotificationManager,
reportMessageManager
reportMessageManager,
multiRecipientMessageExecutor
);
}
@@ -270,7 +291,7 @@ class MessageControllerTest {
resources.getJerseyTest()
.target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID))
.request()
.header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString("1234".getBytes()))
.header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES))
.put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_single_device.json"),
IncomingMessageList.class),
MediaType.APPLICATION_JSON_TYPE));
@@ -412,8 +433,9 @@ class MessageControllerTest {
verifyNoMoreInteractions(messageSender);
}
@Test
void testGetMessages() {
@ParameterizedTest
@MethodSource
void testGetMessages(boolean receiveStories) {
final long timestampOne = 313377;
final long timestampTwo = 313388;
@@ -424,19 +446,15 @@ class MessageControllerTest {
final UUID updatedPniOne = UUID.randomUUID();
List<Envelope> messages = List.of(
List<Envelope> envelopes = List.of(
generateEnvelope(messageGuidOne, Envelope.Type.CIPHERTEXT_VALUE, timestampOne, sourceUuid, 2,
AuthHelper.VALID_UUID, updatedPniOne, "hi there".getBytes(), 0),
AuthHelper.VALID_UUID, updatedPniOne, "hi there".getBytes(), 0, false),
generateEnvelope(messageGuidTwo, Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE, timestampTwo, sourceUuid, 2,
AuthHelper.VALID_UUID, null, null, 0)
AuthHelper.VALID_UUID, null, null, 0, true)
);
OutgoingMessageEntityList messagesList = new OutgoingMessageEntityList(messages.stream()
.map(OutgoingMessageEntity::fromEnvelope)
.toList(), false);
when(messagesManager.getMessagesForDevice(eq(AuthHelper.VALID_UUID), eq(1L), anyBoolean()))
.thenReturn(new Pair<>(messages, false));
.thenReturn(new Pair<>(envelopes, false));
final String userAgent = "Test-UA";
@@ -444,27 +462,39 @@ class MessageControllerTest {
resources.getJerseyTest().target("/v1/messages/")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.header(Stories.X_SIGNAL_RECEIVE_STORIES, receiveStories ? "true" : "false")
.header("USer-Agent", userAgent)
.accept(MediaType.APPLICATION_JSON_TYPE)
.get(OutgoingMessageEntityList.class);
assertEquals(response.messages().size(), 2);
List<OutgoingMessageEntity> messages = response.messages();
int expectedSize = receiveStories ? 2 : 1;
assertEquals(expectedSize, messages.size());
assertEquals(response.messages().get(0).timestamp(), timestampOne);
assertEquals(response.messages().get(1).timestamp(), timestampTwo);
OutgoingMessageEntity first = messages.get(0);
assertEquals(first.timestamp(), timestampOne);
assertEquals(first.guid(), messageGuidOne);
assertEquals(first.sourceUuid(), sourceUuid);
assertEquals(updatedPniOne, first.updatedPni());
assertEquals(response.messages().get(0).guid(), messageGuidOne);
assertEquals(response.messages().get(1).guid(), messageGuidTwo);
assertEquals(response.messages().get(0).sourceUuid(), sourceUuid);
assertEquals(response.messages().get(1).sourceUuid(), sourceUuid);
assertEquals(updatedPniOne, response.messages().get(0).updatedPni());
assertNull(response.messages().get(1).updatedPni());
if (receiveStories) {
OutgoingMessageEntity second = messages.get(1);
assertEquals(second.timestamp(), timestampTwo);
assertEquals(second.guid(), messageGuidTwo);
assertEquals(second.sourceUuid(), sourceUuid);
assertNull(second.updatedPni());
}
verify(pushNotificationManager).handleMessagesRetrieved(AuthHelper.VALID_ACCOUNT, AuthHelper.VALID_DEVICE, userAgent);
}
private static Stream<Arguments> testGetMessages() {
return Stream.of(
Arguments.of(true),
Arguments.of(false)
);
}
@Test
void testGetMessagesBadAuth() {
final long timestampOne = 313377;
@@ -644,7 +674,7 @@ class MessageControllerTest {
resources.getJerseyTest()
.target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID))
.request()
.header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString("1234".getBytes()))
.header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES))
.put(Entity.entity(new IncomingMessageList(
List.of(new IncomingMessage(1, 1L, 1, new String(contentBytes))), false, true,
System.currentTimeMillis()),
@@ -686,14 +716,166 @@ class MessageControllerTest {
);
}
private void writeMultiPayloadRecipient(ByteBuffer bb, long msb, long lsb, int deviceId, int regId) throws Exception {
bb.putLong(msb); // uuid (first 8 bytes)
bb.putLong(lsb); // uuid (last 8 bytes)
int x = deviceId;
// write the device-id in the 7-bit varint format we use, least significant bytes first.
do {
bb.put((byte)(x & 0x7f));
x = x >>> 7;
} while (x != 0);
bb.putShort((short) regId); // registration id short
bb.put(new byte[48]); // key material (48 bytes)
}
private InputStream initializeMultiPayload(UUID recipientUUID, byte[] buffer) throws Exception {
// initialize a binary payload according to our wire format
ByteBuffer bb = ByteBuffer.wrap(buffer);
bb.order(ByteOrder.BIG_ENDIAN);
// determine how many recipient/device pairs we will be writing
int count;
if (recipientUUID == MULTI_DEVICE_UUID) { count = 2; }
else if (recipientUUID == SINGLE_DEVICE_UUID) { count = 1; }
else { throw new Exception("unknown UUID: " + recipientUUID); }
// first write the header header
bb.put(MultiRecipientMessageProvider.VERSION); // version byte
bb.put((byte)count); // count varint, # of active devices for this user
long msb = recipientUUID.getMostSignificantBits();
long lsb = recipientUUID.getLeastSignificantBits();
// write the recipient data for each recipient/device pair
if (recipientUUID == MULTI_DEVICE_UUID) {
writeMultiPayloadRecipient(bb, msb, lsb, MULTI_DEVICE_ID1, MULTI_DEVICE_REG_ID1);
writeMultiPayloadRecipient(bb, msb, lsb, MULTI_DEVICE_ID2, MULTI_DEVICE_REG_ID2);
} else {
writeMultiPayloadRecipient(bb, msb, lsb, SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1);
}
// now write the actual message body (empty for now)
bb.put(new byte[39]); // payload (variable but >= 32, 39 bytes here)
// return the input stream
return new ByteArrayInputStream(buffer, 0, bb.position());
}
@ParameterizedTest
@MethodSource
void testMultiRecipientMessage(UUID recipientUUID, boolean authorize, boolean isStory) throws Exception {
// initialize our binary payload and create an input stream
byte[] buffer = new byte[2048];
InputStream stream = initializeMultiPayload(recipientUUID, buffer);
// set up the entity to use in our PUT request
Entity<InputStream> entity = Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE);
// start building the request
Invocation.Builder bldr = resources
.getJerseyTest()
.target("/v1/messages/multi_recipient")
.queryParam("online", true)
.queryParam("ts", 1663798405641L)
.queryParam("story", isStory)
.request()
.header("User-Agent", "FIXME");
// add access header if needed
if (authorize) {
String encodedBytes = Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES);
bldr = bldr.header(OptionalAccess.UNIDENTIFIED, encodedBytes);
}
// make the PUT request
Response response = bldr.put(entity);
// We have a 2x2x2 grid of possible situations based on:
// - recipient enabled stories?
// - sender is authorized?
// - message is a story?
if (recipientUUID == MULTI_DEVICE_UUID) {
// This is the case where the recipient has enabled stories.
if(isStory) {
// We are sending a story, so we ignore access checks and expect this
// to go out to both the recipient's devices.
checkGoodMultiRecipientResponse(response, 2);
} else {
// We are not sending a story, so we need to do access checks.
if (authorize) {
// When authorized we send a message to the recipient's devices.
checkGoodMultiRecipientResponse(response, 2);
} else {
// When forbidden, we return a 401 error.
checkBadMultiRecipientResponse(response, 401);
}
}
} else {
// This is the case where the recipient has not enabled stories.
if (isStory) {
// We are sending a story, so we ignore access checks.
// this recipient has one device.
checkGoodMultiRecipientResponse(response, 1);
} else {
// We are not sending a story so check access.
if (authorize) {
// If allowed, send a message to the recipient's one device.
checkGoodMultiRecipientResponse(response, 1);
} else {
// If forbidden, return a 401 error.
checkBadMultiRecipientResponse(response, 401);
}
}
}
}
// Arguments here are: recipient-UUID, is-authorized?, is-story?
private static Stream<Arguments> testMultiRecipientMessage() {
return Stream.of(
Arguments.of(MULTI_DEVICE_UUID, false, true),
Arguments.of(MULTI_DEVICE_UUID, false, false),
Arguments.of(SINGLE_DEVICE_UUID, false, true),
Arguments.of(SINGLE_DEVICE_UUID, false, false),
Arguments.of(MULTI_DEVICE_UUID, true, true),
Arguments.of(MULTI_DEVICE_UUID, true, false),
Arguments.of(SINGLE_DEVICE_UUID, true, true),
Arguments.of(SINGLE_DEVICE_UUID, true, false)
);
}
private void checkBadMultiRecipientResponse(Response response, int expectedCode) throws Exception {
assertThat("Unexpected response", response.getStatus(), is(equalTo(expectedCode)));
verify(messageSender, never()).sendMessage(any(), any(), any(), anyBoolean());
verify(multiRecipientMessageExecutor, never()).invokeAll(any());
}
private void checkGoodMultiRecipientResponse(Response response, int expectedCount) throws Exception {
assertThat("Unexpected response", response.getStatus(), is(equalTo(200)));
verify(messageSender, never()).sendMessage(any(), any(), any(), anyBoolean());
ArgumentCaptor<List<Callable<Void>>> captor = ArgumentCaptor.forClass(List.class);
verify(multiRecipientMessageExecutor, times(1)).invokeAll(captor.capture());
assert (captor.getValue().size() == expectedCount);
SendMultiRecipientMessageResponse smrmr = response.readEntity(SendMultiRecipientMessageResponse.class);
assert (smrmr.getUUIDs404().isEmpty());
}
private static Envelope generateEnvelope(UUID guid, int type, long timestamp, UUID sourceUuid,
int sourceDevice, UUID destinationUuid, UUID updatedPni, byte[] content, long serverTimestamp) {
return generateEnvelope(guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni, content, serverTimestamp, false);
}
private static Envelope generateEnvelope(UUID guid, int type, long timestamp, UUID sourceUuid,
int sourceDevice, UUID destinationUuid, UUID updatedPni, byte[] content, long serverTimestamp, boolean story) {
final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()
.setType(MessageProtos.Envelope.Type.forNumber(type))
.setTimestamp(timestamp)
.setServerTimestamp(serverTimestamp)
.setDestinationUuid(destinationUuid.toString())
.setStory(story)
.setServerGuid(guid.toString());
if (sourceUuid != null) {

View File

@@ -120,14 +120,19 @@ class ProfileControllerTest {
private static final RateLimiter usernameRateLimiter = mock(RateLimiter.class);
private static final S3Client s3client = mock(S3Client.class);
private static final PostPolicyGenerator postPolicyGenerator = new PostPolicyGenerator("us-west-1", "profile-bucket", "accessKey");
private static final PostPolicyGenerator postPolicyGenerator = new PostPolicyGenerator("us-west-1", "profile-bucket",
"accessKey");
private static final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
private static final ServerZkProfileOperations zkProfileOperations = mock(ServerZkProfileOperations.class);
private static final byte[] UNIDENTIFIED_ACCESS_KEY = "test-uak".getBytes(StandardCharsets.UTF_8);
private static final String ACCOUNT_IDENTITY_KEY = "barz";
private static final String ACCOUNT_PHONE_NUMBER_IDENTITY_KEY = "bazz";
private static final String ACCOUNT_TWO_IDENTITY_KEY = "bar";
private static final String ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY = "baz";
@SuppressWarnings("unchecked")
private static final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
private static final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(
DynamicConfigurationManager.class);
private DynamicPaymentsConfiguration dynamicPaymentsConfiguration;
private Account profileAccount;
@@ -183,8 +188,8 @@ class ProfileControllerTest {
profileAccount = mock(Account.class);
when(profileAccount.getIdentityKey()).thenReturn("bar");
when(profileAccount.getPhoneNumberIdentityKey()).thenReturn("baz");
when(profileAccount.getIdentityKey()).thenReturn(ACCOUNT_TWO_IDENTITY_KEY);
when(profileAccount.getPhoneNumberIdentityKey()).thenReturn(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY);
when(profileAccount.getUuid()).thenReturn(AuthHelper.VALID_UUID_TWO);
when(profileAccount.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI_TWO);
when(profileAccount.isEnabled()).thenReturn(true);
@@ -199,7 +204,8 @@ class ProfileControllerTest {
Account capabilitiesAccount = mock(Account.class);
when(capabilitiesAccount.getIdentityKey()).thenReturn("barz");
when(capabilitiesAccount.getIdentityKey()).thenReturn(ACCOUNT_IDENTITY_KEY);
when(capabilitiesAccount.getPhoneNumberIdentityKey()).thenReturn(ACCOUNT_PHONE_NUMBER_IDENTITY_KEY);
when(capabilitiesAccount.isEnabled()).thenReturn(true);
when(capabilitiesAccount.isGroupsV2Supported()).thenReturn(true);
when(capabilitiesAccount.isGv1MigrationSupported()).thenReturn(true);
@@ -242,7 +248,7 @@ class ProfileControllerTest {
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(BaseProfileResponse.class);
assertThat(profile.getIdentityKey()).isEqualTo("bar");
assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);
assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
@@ -272,7 +278,7 @@ class ProfileControllerTest {
.header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes()))
.get(BaseProfileResponse.class);
assertThat(profile.getIdentityKey()).isEqualTo("bar");
assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);
assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
@@ -310,7 +316,7 @@ class ProfileControllerTest {
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(BaseProfileResponse.class);
assertThat(profile.getIdentityKey()).isEqualTo("baz");
assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY);
assertThat(profile.getBadges()).isEmpty();
assertThat(profile.getUuid()).isEqualTo(AuthHelper.VALID_PNI_TWO);
assertThat(profile.getCapabilities()).isNotNull();
@@ -742,7 +748,7 @@ class ProfileControllerTest {
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(VersionedProfileResponse.class);
assertThat(profile.getBaseProfileResponse().getIdentityKey()).isEqualTo("bar");
assertThat(profile.getBaseProfileResponse().getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);
assertThat(profile.getName()).isEqualTo("validname");
assertThat(profile.getAbout()).isEqualTo("about");
assertThat(profile.getAboutEmoji()).isEqualTo("emoji");
@@ -1227,9 +1233,14 @@ class ProfileControllerTest {
void testBatchIdentityCheck() {
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
.post(Entity.json(new BatchIdentityCheckRequest(List.of(
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, convertStringToFingerprint("barz")),
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID_TWO, convertStringToFingerprint("bar")),
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, convertStringToFingerprint("baz"))
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, null,
convertStringToFingerprint(ACCOUNT_IDENTITY_KEY)),
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_PNI_TWO,
convertStringToFingerprint(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY)),
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_UUID_TWO,
convertStringToFingerprint(ACCOUNT_TWO_IDENTITY_KEY)),
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, null,
convertStringToFingerprint(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY))
))))) {
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo(200);
@@ -1238,37 +1249,44 @@ class ProfileControllerTest {
assertThat(identityCheckResponse.elements()).isNotNull().isEmpty();
}
Condition<BatchIdentityCheckResponse.Element> isEitherUuid1orUuid2 = new Condition<>(element -> {
if (AuthHelper.VALID_UUID.equals(element.aci())) {
return Arrays.equals(Base64.getDecoder().decode("barz"), element.identityKey());
} else if (AuthHelper.VALID_UUID_TWO.equals(element.aci())) {
return Arrays.equals(Base64.getDecoder().decode("bar"), element.identityKey());
} else {
return false;
}
}, "is either UUID 1 or UUID 2 with the correct identity key");
Condition<BatchIdentityCheckResponse.Element> isAnExpectedUuid = new Condition<>(element -> {
if (AuthHelper.VALID_UUID.equals(element.aci())) {
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_IDENTITY_KEY), element.identityKey());
} else if (AuthHelper.VALID_PNI_TWO.equals(element.uuid())) {
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY), element.identityKey());
} else if (AuthHelper.VALID_UUID_TWO.equals(element.uuid())) {
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_TWO_IDENTITY_KEY), element.identityKey());
} else {
return false;
}
}, "is an expected UUID with the correct identity key");
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
.post(Entity.json(new BatchIdentityCheckRequest(List.of(
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, convertStringToFingerprint("else1234")),
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID_TWO, convertStringToFingerprint("another1")),
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, convertStringToFingerprint("456"))
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, null, convertStringToFingerprint("else1234")),
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_PNI_TWO,
convertStringToFingerprint("another1")),
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_UUID_TWO,
convertStringToFingerprint("another2")),
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, null, convertStringToFingerprint("456"))
))))) {
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo(200);
BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class);
assertThat(identityCheckResponse).isNotNull();
assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2);
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isEitherUuid1orUuid2);
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isEitherUuid1orUuid2);
assertThat(identityCheckResponse.elements()).isNotNull().hasSize(3);
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid);
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid);
assertThat(identityCheckResponse.elements()).element(2).isNotNull().is(isAnExpectedUuid);
}
List<BatchIdentityCheckRequest.Element> largeElementList = new ArrayList<>(List.of(
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, convertStringToFingerprint("else1234")),
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID_TWO, convertStringToFingerprint("another1")),
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, convertStringToFingerprint("456"))));
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, null, convertStringToFingerprint("else1234")),
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_PNI_TWO, convertStringToFingerprint("another1")),
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, null, convertStringToFingerprint("456"))));
for (int i = 0; i < 900; i++) {
largeElementList.add(new BatchIdentityCheckRequest.Element(UUID.randomUUID(), convertStringToFingerprint("abcd")));
largeElementList.add(
new BatchIdentityCheckRequest.Element(UUID.randomUUID(), null, convertStringToFingerprint("abcd")));
}
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
.post(Entity.json(new BatchIdentityCheckRequest(largeElementList)))) {
@@ -1277,12 +1295,104 @@ class ProfileControllerTest {
BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class);
assertThat(identityCheckResponse).isNotNull();
assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2);
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isEitherUuid1orUuid2);
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isEitherUuid1orUuid2);
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid);
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid);
}
}
private byte[] convertStringToFingerprint(String base64) {
@Test
void testBatchIdentityCheckDeserialization() throws Exception {
Condition<BatchIdentityCheckResponse.Element> isAnExpectedUuid = new Condition<>(element -> {
if (AuthHelper.VALID_UUID.equals(element.aci())) {
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_IDENTITY_KEY), element.identityKey());
} else if (AuthHelper.VALID_PNI_TWO.equals(element.uuid())) {
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY), element.identityKey());
} else {
return false;
}
}, "is an expected UUID with the correct identity key");
// null properties are ok to omit
String json = String.format("""
{
"elements": [
{ "aci": "%s", "fingerprint": "%s" },
{ "uuid": "%s", "fingerprint": "%s" },
{ "aci": "%s", "fingerprint": "%s" }
]
}
""", AuthHelper.VALID_UUID, Base64.getEncoder().encodeToString(convertStringToFingerprint("else1234")),
AuthHelper.VALID_PNI_TWO, Base64.getEncoder().encodeToString(convertStringToFingerprint("another1")),
AuthHelper.INVALID_UUID, Base64.getEncoder().encodeToString(convertStringToFingerprint("456")));
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
.post(Entity.entity(json, "application/json"))) {
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo(200);
String responseJson = response.readEntity(String.class);
// `null` properties should be omitted from the response
assertThat(responseJson).doesNotContain("null");
BatchIdentityCheckResponse identityCheckResponse = SystemMapper.getMapper()
.readValue(responseJson, BatchIdentityCheckResponse.class);
assertThat(identityCheckResponse).isNotNull();
assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2);
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid);
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid);
}
}
@ParameterizedTest
@MethodSource
void testBatchIdentityCheckDeserializationBadRequest(final String json) {
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
.post(Entity.entity(json, "application/json"))) {
assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo(400);
}
}
static Stream<Arguments> testBatchIdentityCheckDeserializationBadRequest() {
return Stream.of(
Arguments.of( // aci and uuid cannot both be null
"""
{
"elements": [
{ "aci": null, "uuid": null, "fingerprint": "%s" }
]
}
"""),
Arguments.of( // an empty string is also invalid
"""
{
"elements": [
{ "aci": "", "uuid": null, "fingerprint": "%s" }
]
}
"""
),
Arguments.of( // as is a blank string
"""
{
"elements": [
{ "aci": null, "uuid": " ", "fingerprint": "%s" }
]
}
"""),
Arguments.of( // aci and uuid cannot both be non-null
String.format("""
{
"elements": [
{ "aci": "%s", "uuid": "%s", "fingerprint": "%s" }
]
}
""", AuthHelper.VALID_UUID, AuthHelper.VALID_PNI,
Base64.getEncoder().encodeToString(convertStringToFingerprint("else1234"))))
);
}
private static byte[] convertStringToFingerprint(String base64) {
MessageDigest sha256;
try {
sha256 = MessageDigest.getInstance("SHA-256");

View File

@@ -36,7 +36,8 @@ class OutgoingMessageEntityTest {
updatedPni,
messageContent,
serverTimestamp,
true);
true,
false);
assertEquals(outgoingMessageEntity, OutgoingMessageEntity.fromEnvelope(outgoingMessageEntity.toEnvelope()));
}

View File

@@ -68,7 +68,10 @@ class RateLimitChallengeManagerTest {
when(account.getNumber()).thenReturn("+18005551234");
when(account.getUuid()).thenReturn(UUID.randomUUID());
when(recaptchaClient.verify(any(), any())).thenReturn(successfulChallenge);
when(recaptchaClient.verify(any(), any()))
.thenReturn(successfulChallenge
? new RecaptchaClient.AssessmentResult(true, "")
: RecaptchaClient.AssessmentResult.invalid());
when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));
when(rateLimiters.getRecaptchaChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class));

View File

@@ -70,7 +70,7 @@ class MessageMetricsTest {
}
private OutgoingMessageEntity createOutgoingMessageEntity(UUID destinationUuid) {
return new OutgoingMessageEntity(UUID.randomUUID(), 1, 1L, null, 1, destinationUuid, null, new byte[]{}, 1, true);
return new OutgoingMessageEntity(UUID.randomUUID(), 1, 1L, null, 1, destinationUuid, null, new byte[]{}, 1, true, false);
}
@Test

View File

@@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.recaptcha;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient.SEPARATOR;
@@ -43,6 +44,21 @@ class RecaptchaClientTest {
});
}
@ParameterizedTest
@MethodSource
void scoreString(float score, String expected) {
assertThat(RecaptchaClient.scoreString(score)).isEqualTo(expected);
}
static Stream<Arguments> scoreString() {
return Stream.of(
Arguments.of(0.3f, "30"),
Arguments.of(0.0f, "0"),
Arguments.of(0.333f, "33"),
Arguments.of(Float.NaN, "0")
);
}
static Stream<Arguments> parseInputToken() {
return Stream.of(
Arguments.of(

View File

@@ -19,8 +19,8 @@ import io.lettuce.core.RedisCommandTimeoutException;
import io.lettuce.core.RedisException;
import io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection;
import io.lettuce.core.cluster.pubsub.api.sync.RedisClusterPubSubCommands;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
@@ -32,25 +32,28 @@ class FaultTolerantPubSubConnectionTest {
@SuppressWarnings("unchecked")
@BeforeEach
public void setUp() {
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = mock(StatefulRedisClusterPubSubConnection.class);
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = mock(
StatefulRedisClusterPubSubConnection.class);
pubSubCommands = mock(RedisClusterPubSubCommands.class);
pubSubCommands = mock(RedisClusterPubSubCommands.class);
when(pubSubConnection.sync()).thenReturn(pubSubCommands);
when(pubSubConnection.sync()).thenReturn(pubSubCommands);
final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration();
breakerConfiguration.setFailureRateThreshold(100);
breakerConfiguration.setRingBufferSizeInClosedState(1);
breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE);
final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration();
breakerConfiguration.setFailureRateThreshold(100);
breakerConfiguration.setSlidingWindowSize(1);
breakerConfiguration.setSlidingWindowMinimumNumberOfCalls(1);
breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE);
final RetryConfiguration retryConfiguration = new RetryConfiguration();
retryConfiguration.setMaxAttempts(3);
retryConfiguration.setWaitDuration(0);
final RetryConfiguration retryConfiguration = new RetryConfiguration();
retryConfiguration.setMaxAttempts(3);
retryConfiguration.setWaitDuration(0);
final CircuitBreaker circuitBreaker = CircuitBreaker.of("test", breakerConfiguration.toCircuitBreakerConfig());
final Retry retry = Retry.of("test", retryConfiguration.toRetryConfig());
final CircuitBreaker circuitBreaker = CircuitBreaker.of("test", breakerConfiguration.toCircuitBreakerConfig());
final Retry retry = Retry.of("test", retryConfiguration.toRetryConfig());
faultTolerantPubSubConnection = new FaultTolerantPubSubConnection<>("test", pubSubConnection, circuitBreaker, retry);
faultTolerantPubSubConnection = new FaultTolerantPubSubConnection<>("test", pubSubConnection, circuitBreaker,
retry);
}
@Test

View File

@@ -22,8 +22,8 @@ import io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection;
import io.lettuce.core.event.EventBus;
import io.lettuce.core.resource.ClientResources;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
import reactor.core.publisher.Flux;
@@ -44,23 +44,25 @@ class FaultTolerantRedisClusterTest {
clusterCommands = mock(RedisAdvancedClusterCommands.class);
when(clusterClient.connect()).thenReturn(clusterConnection);
when(clusterClient.connectPubSub()).thenReturn(pubSubConnection);
when(clusterClient.getResources()).thenReturn(clientResources);
when(clusterConnection.sync()).thenReturn(clusterCommands);
when(clientResources.eventBus()).thenReturn(eventBus);
when(eventBus.get()).thenReturn(mock(Flux.class));
when(clusterClient.connect()).thenReturn(clusterConnection);
when(clusterClient.connectPubSub()).thenReturn(pubSubConnection);
when(clusterClient.getResources()).thenReturn(clientResources);
when(clusterConnection.sync()).thenReturn(clusterCommands);
when(clientResources.eventBus()).thenReturn(eventBus);
when(eventBus.get()).thenReturn(mock(Flux.class));
final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration();
breakerConfiguration.setFailureRateThreshold(100);
breakerConfiguration.setRingBufferSizeInClosedState(1);
breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE);
final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration();
breakerConfiguration.setFailureRateThreshold(100);
breakerConfiguration.setSlidingWindowSize(1);
breakerConfiguration.setSlidingWindowMinimumNumberOfCalls(1);
breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE);
final RetryConfiguration retryConfiguration = new RetryConfiguration();
retryConfiguration.setMaxAttempts(3);
retryConfiguration.setWaitDuration(0);
final RetryConfiguration retryConfiguration = new RetryConfiguration();
retryConfiguration.setMaxAttempts(3);
retryConfiguration.setWaitDuration(0);
faultTolerantCluster = new FaultTolerantRedisCluster("test", clusterClient, Duration.ofSeconds(2), breakerConfiguration, retryConfiguration);
faultTolerantCluster = new FaultTolerantRedisCluster("test", clusterClient, Duration.ofSeconds(2),
breakerConfiguration, retryConfiguration);
}
@Test

View File

@@ -193,7 +193,7 @@ class AccountsManagerChangeNumberIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProhibitedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
secureStorageClient,

View File

@@ -160,7 +160,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProhibitedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
mock(SecureStorageClient.class),

View File

@@ -39,6 +39,7 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -71,7 +72,7 @@ class AccountsManagerTest {
private Keys keys;
private MessagesManager messagesManager;
private ProfilesManager profilesManager;
private ReservedUsernames reservedUsernames;
private ProhibitedUsernames prohibitedUsernames;
private ExperimentEnrollmentManager enrollmentManager;
private Map<String, UUID> phoneNumberIdentifiersByE164;
@@ -87,6 +88,8 @@ class AccountsManagerTest {
return null;
};
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
@BeforeEach
void setup() throws InterruptedException {
accounts = mock(Accounts.class);
@@ -95,7 +98,7 @@ class AccountsManagerTest {
keys = mock(Keys.class);
messagesManager = mock(MessagesManager.class);
profilesManager = mock(ProfilesManager.class);
reservedUsernames = mock(ReservedUsernames.class);
prohibitedUsernames = mock(ProhibitedUsernames.class);
//noinspection unchecked
commands = mock(RedisAdvancedClusterCommands.class);
@@ -149,7 +152,7 @@ class AccountsManagerTest {
directoryQueue,
keys,
messagesManager,
reservedUsernames,
prohibitedUsernames,
profilesManager,
mock(StoredVerificationCodeManager.class),
storageClient,
@@ -737,11 +740,70 @@ class AccountsManagerTest {
verify(accounts).setUsername(eq(account), startsWith(nickname));
}
@Test
void testReserveUsername() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "beethoven";
accountsManager.reserveUsername(account, nickname);
verify(accounts).reserveUsername(eq(account), startsWith(nickname), any());
}
@Test
void testSetReservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String reserved = "scooby.1234";
setReservationHash(account, reserved);
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN);
verify(accounts).confirmUsername(eq(account), eq(reserved), eq(RESERVATION_TOKEN));
}
@Test
void testSetReservedHashNameMismatch() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
setReservationHash(account, "pluto.1234");
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq("pluto.1234"))).thenReturn(true);
assertThrows(UsernameReservationNotFoundException.class,
() -> accountsManager.confirmReservedUsername(account, "goofy.1234", RESERVATION_TOKEN));
}
@Test
void testSetReservedHashAciMismatch() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String reserved = "toto.1234";
account.setReservedUsernameHash(Accounts.reservedUsernameHash(UUID.randomUUID(), reserved));
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
assertThrows(UsernameReservationNotFoundException.class,
() -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
}
@Test
void testSetReservedLapsed() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String reserved = "porkchop.1234";
// name was reserved, but the reservation lapsed and another account took it
setReservationHash(account, reserved);
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(false);
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
verify(accounts, never()).confirmUsername(any(), any(), any());
}
@Test
void testSetReservedRetry() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String username = "santaslittlehelper.1234";
account.setUsername(username);
// reserved username already set, should be treated as a replay
accountsManager.confirmReservedUsername(account, username, RESERVATION_TOKEN);
verifyNoInteractions(accounts);
}
@Test
void testSetUsernameSameUsername() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "test";
account.setUsername(nickname + "#123");
account.setUsername(nickname + ".123");
// should be treated as a replayed request
assertDoesNotThrow(() -> accountsManager.setUsername(account, nickname, null));
@@ -752,7 +814,7 @@ class AccountsManagerTest {
void testSetUsernameReroll() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "test";
final String username = nickname + "#ZZZ";
final String username = nickname + ".ZZZ";
account.setUsername(username);
// given the correct old username, should reroll discriminator even if the nick matches
@@ -761,12 +823,34 @@ class AccountsManagerTest {
}
@Test
void testSetUsernameExpandDiscriminator() throws UsernameNotAvailableException {
void testReserveUsernameReroll() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "clifford";
final String username = nickname + ".ZZZ";
account.setUsername(username);
// given the correct old username, should reroll discriminator even if the nick matches
accountsManager.reserveUsername(account, nickname);
verify(accounts).reserveUsername(eq(account), and(startsWith(nickname), not(eq(username))), any());
}
@Test
void testSetReservedUsernameWithNoReservation() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
new ArrayList<>(), new byte[16]);
assertThrows(UsernameReservationNotFoundException.class,
() -> accountsManager.confirmReservedUsername(account, "laika.1234", RESERVATION_TOKEN));
verify(accounts, never()).confirmUsername(any(), any(), any());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void testUsernameExpandDiscriminator(boolean reserve) throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "test";
ArgumentMatcher<String> isWide = (String username) -> {
String[] spl = username.split(UsernameGenerator.SEPARATOR);
String[] spl = username.split(Pattern.quote(UsernameGenerator.SEPARATOR));
assertEquals(spl.length, 2);
int discriminator = Integer.parseInt(spl[1]);
// require a 7 digit discriminator
@@ -775,16 +859,22 @@ class AccountsManagerTest {
when(accounts.usernameAvailable(any())).thenReturn(false);
when(accounts.usernameAvailable(argThat(isWide))).thenReturn(true);
accountsManager.setUsername(account, nickname, null);
verify(accounts).setUsername(eq(account), and(startsWith(nickname), argThat(isWide)));
if (reserve) {
accountsManager.reserveUsername(account, nickname);
verify(accounts).reserveUsername(eq(account), and(startsWith(nickname), argThat(isWide)), any());
} else {
accountsManager.setUsername(account, nickname, null);
verify(accounts).setUsername(eq(account), and(startsWith(nickname), argThat(isWide)));
}
}
@Test
void testChangeUsername() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "test";
account.setUsername("old#123");
accountsManager.setUsername(account, nickname, "old#123");
account.setUsername("old.123");
accountsManager.setUsername(account, nickname, "old.123");
verify(accounts).setUsername(eq(account), startsWith(nickname));
}
@@ -801,7 +891,7 @@ class AccountsManagerTest {
@Test
void testSetUsernameReserved() {
final String nickname = "reserved";
when(reservedUsernames.isReserved(eq(nickname), any())).thenReturn(true);
when(prohibitedUsernames.isProhibited(eq(nickname), any())).thenReturn(true);
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
@@ -823,6 +913,10 @@ class AccountsManagerTest {
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
}
private void setReservationHash(final Account account, final String reservedUsername) {
account.setReservedUsernameHash(Accounts.reservedUsernameHash(account.getUuid(), reservedUsername));
}
private static Device generateTestDevice(final long lastSeen) {
final Device device = new Device();
device.setId(Device.MASTER_ID);

View File

@@ -8,6 +8,8 @@ package org.whispersystems.textsecuregcm.storage;
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.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
@@ -22,6 +24,8 @@ import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;
@@ -110,7 +114,11 @@ class AccountsManagerUsernameIntegrationTest {
.build();
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest);
buildAccountsManager(1, 2, 10);
}
private void buildAccountsManager(final int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth)
throws InterruptedException {
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
mock(DynamicConfigurationManager.class);
@@ -127,6 +135,8 @@ class AccountsManagerUsernameIntegrationTest {
USERNAMES_TABLE_NAME,
SCAN_PAGE_SIZE));
usernameGenerator = new UsernameGenerator(initialWidth, discriminatorMaxWidth, attemptsPerWidth,
Duration.ofDays(1));
final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class);
doAnswer((final InvocationOnMock invocationOnMock) -> {
@SuppressWarnings("unchecked")
@@ -141,8 +151,6 @@ class AccountsManagerUsernameIntegrationTest {
final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME)))
.thenReturn(true);
usernameGenerator = new UsernameGenerator(1, 2, 10);
accountsManager = new AccountsManager(
accounts,
phoneNumberIdentifiers,
@@ -151,7 +159,7 @@ class AccountsManagerUsernameIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProhibitedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
mock(SecureStorageClient.class),
@@ -163,7 +171,7 @@ class AccountsManagerUsernameIntegrationTest {
}
private static int discriminator(String username) {
return Integer.parseInt(username.split(UsernameGenerator.SEPARATOR)[1]);
return Integer.parseInt(username.substring(username.indexOf(UsernameGenerator.SEPARATOR) + 1));
}
@Test
@@ -190,19 +198,32 @@ class AccountsManagerUsernameIntegrationTest {
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@Test
void testNoUsernames() throws InterruptedException {
@ParameterizedTest
@ValueSource(booleans = {false, true})
void testNoUsernames(boolean reserve) throws InterruptedException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
for (int i = 1; i <= 99; i++) {
final Map<String, AttributeValue> item = new HashMap<>(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))));
// half of these are taken usernames, half are only reservations (have a TTL)
if (i % 2 == 0) {
item.put(Accounts.ATTR_TTL,
AttributeValues.fromLong(Instant.now().plus(Duration.ofMinutes(1)).getEpochSecond()));
}
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.item(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))))
.item(item)
.build());
}
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
assertThrows(UsernameNotAvailableException.class, () -> {
if (reserve) {
accountsManager.reserveUsername(account, "n00bkiller");
} else {
accountsManager.setUsername(account, "n00bkiller", null);
}
});
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@@ -237,4 +258,112 @@ class AccountsManagerUsernameIntegrationTest {
verify(accounts, times(1)).usernameAvailable(argThat(un -> discriminator(un) >= 10));
}
@Test
public void testReserveSetClear()
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "n00bkiller");
account = reservation.account();
assertThat(account.getReservedUsernameHash()).isPresent();
assertThat(reservation.reservedUsername()).startsWith("n00bkiller");
int discriminator = discriminator(reservation.reservedUsername());
assertThat(discriminator).isGreaterThan(0).isLessThan(10);
assertThat(accountsManager.getByUsername(reservation.reservedUsername())).isEmpty();
account = accountsManager.confirmReservedUsername(
account,
reservation.reservedUsername(),
reservation.reservationToken());
assertThat(account.getUsername().get()).startsWith("n00bkiller");
assertThat(accountsManager.getByUsername(account.getUsername().get()).orElseThrow().getUuid()).isEqualTo(
account.getUuid());
// reroll
reservation = accountsManager.reserveUsername(account, "n00bkiller");
account = reservation.account();
account = accountsManager.confirmReservedUsername(
account,
reservation.reservedUsername(),
reservation.reservationToken());
final String newUsername = account.getUsername().orElseThrow();
assertThat(discriminator(account.getUsername().orElseThrow())).isNotEqualTo(discriminator);
// clear
account = accountsManager.clearUsername(account);
assertThat(accountsManager.getByUsername(newUsername)).isEmpty();
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@Test
public void testReservationLapsed()
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
// use a username generator that can retry a lot
buildAccountsManager(1, 1, 1000000);
final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
AccountsManager.UsernameReservation reservation1 = accountsManager.reserveUsername(account, "n00bkiller");
final String reservedUsername = reservation1.reservedUsername();
long past = Instant.now().minus(Duration.ofMinutes(1)).getEpochSecond();
// force expiration
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().updateItem(UpdateItemRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString(reservedUsername)))
.updateExpression("SET #ttl = :ttl")
.expressionAttributeNames(Map.of("#ttl", Accounts.ATTR_TTL))
.expressionAttributeValues(Map.of(":ttl", AttributeValues.fromLong(past)))
.build());
int discriminator = discriminator(reservedUsername);
// use up all names except the reserved one
for (int i = 1; i <= 9; i++) {
if (i == discriminator) {
continue;
}
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.item(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))))
.build());
}
// a different account should be able to reserve it
Account account2 = accountsManager.create("+18005552222", "password", null, new AccountAttributes(),
new ArrayList<>());
final AccountsManager.UsernameReservation reservation2 = accountsManager.reserveUsername(account2, "n00bkiller"
);
assertThat(reservation2.reservedUsername()).isEqualTo(reservedUsername);
assertThrows(UsernameNotAvailableException.class,
() -> accountsManager.confirmReservedUsername(reservation1.account(), reservedUsername, reservation1.reservationToken()));
accountsManager.confirmReservedUsername(reservation2.account(), reservedUsername, reservation2.reservationToken());
}
@Test
void testUsernameReserveClearSetReserved()
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
account = accountsManager.setUsername(account, "n00bkiller", null);
final AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "other");
account = reservation.account();
assertThat(reservation.reservedUsername()).startsWith("other");
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("n00bkiller"));
account = accountsManager.clearUsername(account);
assertThat(account.getReservedUsernameHash()).isPresent();
assertThat(account.getUsername()).isEmpty();
account = accountsManager.confirmReservedUsername(account, reservation.reservedUsername(), reservation.reservationToken());
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("other"));
}
}

View File

@@ -17,6 +17,9 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.uuid.UUIDComparator;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -26,6 +29,7 @@ import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -74,6 +78,7 @@ class AccountsTest {
.build())
.build();
private Clock mockClock;
private DynamicConfigurationManager<DynamicConfiguration> mockDynamicConfigManager;
private Accounts accounts;
@@ -130,7 +135,11 @@ class AccountsTest {
when(mockDynamicConfigManager.getConfiguration())
.thenReturn(new DynamicConfiguration());
mockClock = mock(Clock.class);
when(mockClock.instant()).thenReturn(Instant.EPOCH);
this.accounts = new Accounts(
mockClock,
mockDynamicConfigManager,
dynamoDbExtension.getDynamoDbClient(),
dynamoDbExtension.getDynamoDbAsyncClient(),
@@ -608,7 +617,7 @@ class AccountsTest {
}
@Test
void testSetUsername() throws UsernameNotAvailableException {
void testSetUsername() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
@@ -679,7 +688,7 @@ class AccountsTest {
}
@Test
void testClearUsername() throws UsernameNotAvailableException {
void testClearUsername() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
@@ -704,7 +713,7 @@ class AccountsTest {
}
@Test
void testClearUsernameVersionMismatch() throws UsernameNotAvailableException {
void testClearUsernameVersionMismatch() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
@@ -719,6 +728,156 @@ class AccountsTest {
assertThat(account.getUsername()).hasValueSatisfying(u -> assertThat(u).isEqualTo(username));
}
@Test
void testReservedUsername() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account2);
final UUID token = accounts.reserveUsername(account1, "garfield", Duration.ofDays(1));
assertThat(account1.getReservedUsernameHash()).get().isEqualTo(Accounts.reservedUsernameHash(account1.getUuid(), "garfield"));
assertThat(account1.getUsername()).isEmpty();
// account 2 shouldn't be able to reserve the username
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account2, "garfield", Duration.ofDays(1)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.confirmUsername(account2, "garfield", UUID.randomUUID()));
assertThat(accounts.getByUsername("garfield")).isEmpty();
accounts.confirmUsername(account1, "garfield", token);
assertThat(account1.getReservedUsernameHash()).isEmpty();
assertThat(account1.getUsername()).get().isEqualTo("garfield");
assertThat(accounts.getByUsername("garfield").get().getUuid()).isEqualTo(account1.getUuid());
assertThat(dynamoDbExtension.getDynamoDbClient()
.getItem(GetItemRequest.builder()
.tableName(USERNAME_CONSTRAINT_TABLE_NAME)
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString("garfield")))
.build())
.item())
.doesNotContainKey(Accounts.ATTR_TTL);
}
@Test
void testUsernameAvailable() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final String username = "unsinkablesam";
final UUID token = accounts.reserveUsername(account1, username, Duration.ofDays(1));
assertThat(accounts.usernameAvailable(username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isTrue();
accounts.confirmUsername(account1, username, token);
assertThat(accounts.usernameAvailable(username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isFalse();
}
@Test
void testReservedUsernameWrongToken() {
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
accounts.reserveUsername(account, "grumpy", Duration.ofDays(1));
assertThat(account.getReservedUsernameHash())
.get()
.isEqualTo(Accounts.reservedUsernameHash(account.getUuid(), "grumpy"));
assertThat(account.getUsername()).isEmpty();
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.confirmUsername(account, "grumpy", UUID.randomUUID()));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account, "grumpy"));
}
@Test
void testReserveExpiredReservedUsername() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account2);
final String username = "snowball.02";
accounts.reserveUsername(account1, username, Duration.ofDays(2));
Supplier<UUID> take = () -> accounts.reserveUsername(account2, username, Duration.ofDays(2));
for (int i = 0; i <= 2; i++) {
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(i)));
assertThrows(ContestedOptimisticLockException.class, take::get);
}
// after 2 days, can take the name
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
final UUID token = take.get();
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account1, username, Duration.ofDays(2)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account1, username));
accounts.confirmUsername(account2, username, token);
assertThat(accounts.getByUsername(username).get().getUuid()).isEqualTo(account2.getUuid());
}
@Test
void testTakeExpiredReservedUsername() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account2);
final String username = "simon.123";
accounts.reserveUsername(account1, username, Duration.ofDays(2));
Runnable take = () -> accounts.setUsername(account2, username);
for (int i = 0; i <= 2; i++) {
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(i)));
assertThrows(ContestedOptimisticLockException.class, take::run);
}
// after 2 days, can take the name
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
take.run();
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account1, username, Duration.ofDays(2)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account1, username));
assertThat(accounts.getByUsername(username).get().getUuid()).isEqualTo(account2.getUuid());
}
@Test
void testRetryReserveUsername() {
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
accounts.reserveUsername(account, "jorts", Duration.ofDays(2));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account, "jorts", Duration.ofDays(2)),
"Shouldn't be able to re-reserve same username (would extend ttl)");
}
@Test
void testReserveUsernameVersionConflict() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
account.setVersion(account.getVersion() + 12);
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account, "salem", Duration.ofDays(1)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account, "salem"));
}
private Device generateDevice(long id) {
return DevicesHelper.createDevice(id);
}

View File

@@ -17,37 +17,37 @@ import org.junit.jupiter.params.provider.MethodSource;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
class ReservedUsernamesTest {
class ProhibitedUsernamesTest {
private static final String RESERVED_USERNAMES_TABLE_NAME = "reserved_usernames_test";
@RegisterExtension
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
.tableName(RESERVED_USERNAMES_TABLE_NAME)
.hashKey(ReservedUsernames.KEY_PATTERN)
.hashKey(ProhibitedUsernames.KEY_PATTERN)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(ReservedUsernames.KEY_PATTERN)
.attributeName(ProhibitedUsernames.KEY_PATTERN)
.attributeType(ScalarAttributeType.S)
.build())
.build();
private static final UUID RESERVED_FOR_UUID = UUID.randomUUID();
private ReservedUsernames reservedUsernames;
private ProhibitedUsernames prohibitedUsernames;
@BeforeEach
void setUp() {
reservedUsernames =
new ReservedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
prohibitedUsernames =
new ProhibitedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
}
@ParameterizedTest
@MethodSource
void isReserved(final String username, final UUID uuid, final boolean expectReserved) {
reservedUsernames.reserveUsername(".*myusername.*", RESERVED_FOR_UUID);
reservedUsernames.reserveUsername("^foobar$", RESERVED_FOR_UUID);
prohibitedUsernames.prohibitUsername(".*myusername.*", RESERVED_FOR_UUID);
prohibitedUsernames.prohibitUsername("^foobar$", RESERVED_FOR_UUID);
assertEquals(expectReserved, reservedUsernames.isReserved(username, uuid));
assertEquals(expectReserved, prohibitedUsernames.isProhibited(username, uuid));
}
private static Stream<Arguments> isReserved() {

View File

@@ -17,7 +17,7 @@ class AuthenticationCredentialsTest {
AuthenticationCredentials credentials = new AuthenticationCredentials("mypassword");
assertThat(credentials.getSalt()).isNotEmpty();
assertThat(credentials.getHashedAuthenticationToken()).isNotEmpty();
assertThat(credentials.getHashedAuthenticationToken().length()).isEqualTo(40);
assertThat(credentials.getHashedAuthenticationToken().length()).isEqualTo(66);
}
@Test

View File

@@ -73,10 +73,13 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
@@ -99,6 +102,7 @@ import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.Hex;
@@ -121,6 +125,7 @@ class AccountControllerTest {
private static final UUID SENDER_REG_LOCK_UUID = UUID.randomUUID();
private static final UUID SENDER_TRANSFER_UUID = UUID.randomUUID();
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
private static final String ABUSIVE_HOST = "192.168.1.1";
private static final String NICE_HOST = "127.0.0.1";
@@ -144,6 +149,7 @@ class AccountControllerTest {
private static RateLimiter smsVoicePrefixLimiter = mock(RateLimiter.class);
private static RateLimiter autoBlockLimiter = mock(RateLimiter.class);
private static RateLimiter usernameSetLimiter = mock(RateLimiter.class);
private static RateLimiter usernameReserveLimiter = mock(RateLimiter.class);
private static RateLimiter usernameLookupLimiter = mock(RateLimiter.class);
private static SmsSender smsSender = mock(SmsSender.class);
private static TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class);
@@ -208,6 +214,7 @@ class AccountControllerTest {
when(rateLimiters.getSmsVoicePrefixLimiter()).thenReturn(smsVoicePrefixLimiter);
when(rateLimiters.getAutoBlockLimiter()).thenReturn(autoBlockLimiter);
when(rateLimiters.getUsernameSetLimiter()).thenReturn(usernameSetLimiter);
when(rateLimiters.getUsernameReserveLimiter()).thenReturn(usernameReserveLimiter);
when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
@@ -293,8 +300,10 @@ class AccountControllerTest {
when(abusiveHostRules.isBlocked(eq(ABUSIVE_HOST))).thenReturn(true);
when(abusiveHostRules.isBlocked(eq(NICE_HOST))).thenReturn(false);
when(recaptchaClient.verify(eq(INVALID_CAPTCHA_TOKEN), anyString())).thenReturn(false);
when(recaptchaClient.verify(eq(VALID_CAPTCHA_TOKEN), anyString())).thenReturn(true);
when(recaptchaClient.verify(eq(INVALID_CAPTCHA_TOKEN), anyString()))
.thenReturn(RecaptchaClient.AssessmentResult.invalid());
when(recaptchaClient.verify(eq(VALID_CAPTCHA_TOKEN), anyString()))
.thenReturn(new RecaptchaClient.AssessmentResult(true, ""));
doThrow(new RateLimitExceededException(Duration.ZERO)).when(pinLimiter).validate(eq(SENDER_OVER_PIN));
@@ -319,6 +328,7 @@ class AccountControllerTest {
smsVoicePrefixLimiter,
autoBlockLimiter,
usernameSetLimiter,
usernameReserveLimiter,
usernameLookupLimiter,
smsSender,
turnTokenGenerator,
@@ -695,7 +705,7 @@ class AccountControllerTest {
.header("X-Forwarded-For", NICE_HOST)
.get();
assertThat(response.getStatus()).isEqualTo(402);
assertThat(response.getStatus()).isEqualTo(403);
verifyNoMoreInteractions(smsSender);
verifyNoMoreInteractions(abusiveHostRules);
@@ -1630,7 +1640,7 @@ class AccountControllerTest {
assertThat(pinCapture.getValue()).isNotEmpty();
assertThat(pinSaltCapture.getValue()).isNotEmpty();
assertThat(pinCapture.getValue().length()).isEqualTo(40);
assertThat(pinCapture.getValue().length()).isEqualTo(66);
}
@Test
@@ -1720,7 +1730,7 @@ class AccountControllerTest {
@Test
void testSetUsername() throws UsernameNotAvailableException {
Account account = mock(Account.class);
when(account.getUsername()).thenReturn(Optional.of("n00bkiller#1234"));
when(account.getUsername()).thenReturn(Optional.of("n00bkiller.1234"));
when(accountsManager.setUsername(any(), eq("n00bkiller"), isNull()))
.thenReturn(account);
Response response =
@@ -1730,7 +1740,64 @@ class AccountControllerTest {
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new UsernameRequest("n00bkiller", null)));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller.1234");
}
@Test
void testReserveUsername() throws UsernameNotAvailableException {
when(accountsManager.reserveUsername(any(), eq("n00bkiller")))
.thenReturn(new AccountsManager.UsernameReservation(null, "n00bkiller.1234", RESERVATION_TOKEN));
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/reserved")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ReserveUsernameRequest("n00bkiller")));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(ReserveUsernameResponse.class))
.satisfies(r -> r.username().equals("n00bkiller.1234"))
.satisfies(r -> r.reservationToken().equals(RESERVATION_TOKEN));
}
@Test
void testCommitUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
Account account = mock(Account.class);
when(account.getUsername()).thenReturn(Optional.of("n00bkiller.1234"));
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller.1234"), eq(RESERVATION_TOKEN))).thenReturn(account);
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/confirm")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller.1234", RESERVATION_TOKEN)));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller.1234");
}
@Test
void testCommitUnreservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller.1234"), eq(RESERVATION_TOKEN)))
.thenThrow(new UsernameReservationNotFoundException());
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/confirm")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller.1234", RESERVATION_TOKEN)));
assertThat(response.getStatus()).isEqualTo(409);
}
@Test
void testCommitLapsedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller.1234"), eq(RESERVATION_TOKEN)))
.thenThrow(new UsernameNotAvailableException());
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/confirm")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller.1234", RESERVATION_TOKEN)));
assertThat(response.getStatus()).isEqualTo(410);
}
@Test
@@ -2003,9 +2070,9 @@ class AccountControllerTest {
final UUID uuid = UUID.randomUUID();
when(account.getUuid()).thenReturn(uuid);
when(accountsManager.getByUsername(eq("n00bkiller#1234"))).thenReturn(Optional.of(account));
when(accountsManager.getByUsername(eq("n00bkiller.1234"))).thenReturn(Optional.of(account));
Response response = resources.getJerseyTest()
.target("v1/accounts/username/n00bkiller#1234")
.target("v1/accounts/username/n00bkiller.1234")
.request()
.header("X-Forwarded-For", "127.0.0.1")
.get();
@@ -2015,9 +2082,9 @@ class AccountControllerTest {
@Test
void testLookupUsernameDoesNotExist() {
when(accountsManager.getByUsername(eq("n00bkiller#1234"))).thenReturn(Optional.empty());
when(accountsManager.getByUsername(eq("n00bkiller.1234"))).thenReturn(Optional.empty());
assertThat(resources.getJerseyTest()
.target("v1/accounts/username/n00bkiller#1234")
.target("v1/accounts/username/n00bkiller.1234")
.request()
.header("X-Forwarded-For", "127.0.0.1")
.get().getStatus()).isEqualTo(404);
@@ -2027,7 +2094,7 @@ class AccountControllerTest {
void testLookupUsernameRateLimited() throws RateLimitExceededException {
doThrow(new RateLimitExceededException(Duration.ofSeconds(13))).when(usernameLookupLimiter).validate("127.0.0.1");
final Response response = resources.getJerseyTest()
.target("/v1/accounts/username/test#123")
.target("/v1/accounts/username/test.123")
.request()
.header("X-Forwarded-For", "127.0.0.1")
.get();

View File

@@ -35,13 +35,10 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV1;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV1;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV2;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;
import org.whispersystems.textsecuregcm.entities.AttachmentUri;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
@@ -83,7 +80,6 @@ class AttachmentControllerTest {
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
.setMapper(SystemMapper.getMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new AttachmentControllerV1(rateLimiters, "accessKey", "accessSecret", "attachment-bucket"))
.addResource(new AttachmentControllerV2(rateLimiters, "accessKey", "accessSecret", "us-east-1", "attachmentv2-bucket"))
.addResource(new AttachmentControllerV3(rateLimiters, "some-cdn.signal.org", "signal@example.com", 1000, "/attach-here", RSA_PRIVATE_KEY_PEM))
.build();
@@ -198,53 +194,4 @@ class AttachmentControllerTest {
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
void testAcceleratedPut() {
AttachmentDescriptorV1 descriptor = resources.getJerseyTest()
.target("/v1/attachments/")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(AttachmentDescriptorV1.class);
assertThat(descriptor.getLocation()).startsWith("https://attachment-bucket.s3-accelerate.amazonaws.com");
assertThat(descriptor.getId()).isGreaterThan(0);
assertThat(descriptor.getIdString()).isNotBlank();
}
@Test
void testUnacceleratedPut() {
AttachmentDescriptorV1 descriptor = resources.getJerseyTest()
.target("/v1/attachments/")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))
.get(AttachmentDescriptorV1.class);
assertThat(descriptor.getLocation()).startsWith("https://s3.amazonaws.com");
assertThat(descriptor.getId()).isGreaterThan(0);
assertThat(descriptor.getIdString()).isNotBlank();
}
@Test
void testAcceleratedGet() throws MalformedURLException {
AttachmentUri uri = resources.getJerseyTest()
.target("/v1/attachments/1234")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(AttachmentUri.class);
assertThat(uri.getLocation().getHost()).isEqualTo("attachment-bucket.s3-accelerate.amazonaws.com");
}
@Test
void testUnacceleratedGet() throws MalformedURLException {
AttachmentUri uri = resources.getJerseyTest()
.target("/v1/attachments/1234")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))
.get(AttachmentUri.class);
assertThat(uri.getLocation().getHost()).isEqualTo("s3.amazonaws.com");
}
}

View File

@@ -35,6 +35,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.signal.event.NoOpAdminEventLogger;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.controllers.RemoteConfigController;
@@ -57,7 +58,7 @@ class RemoteConfigControllerTest {
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addProvider(new DeviceLimitExceededExceptionMapper())
.addResource(new RemoteConfigController(remoteConfigsManager, remoteConfigsAuth, Map.of("maxGroupSize", "42")))
.addResource(new RemoteConfigController(remoteConfigsManager, new NoOpAdminEventLogger(), remoteConfigsAuth, Map.of("maxGroupSize", "42")))
.build();

View File

@@ -95,18 +95,19 @@ class FaultTolerantHttpClientTest {
@Test
void testNetworkFailureCircuitBreaker() throws InterruptedException {
CircuitBreakerConfiguration circuitBreakerConfiguration = new CircuitBreakerConfiguration();
circuitBreakerConfiguration.setRingBufferSizeInClosedState(2);
circuitBreakerConfiguration.setRingBufferSizeInHalfOpenState(1);
circuitBreakerConfiguration.setSlidingWindowSize(2);
circuitBreakerConfiguration.setSlidingWindowMinimumNumberOfCalls(2);
circuitBreakerConfiguration.setPermittedNumberOfCallsInHalfOpenState(1);
circuitBreakerConfiguration.setFailureRateThreshold(50);
circuitBreakerConfiguration.setWaitDurationInOpenStateInSeconds(1);
FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder()
.withCircuitBreaker(circuitBreakerConfiguration)
.withRetry(new RetryConfiguration())
.withExecutor(Executors.newSingleThreadExecutor())
.withName("test")
.withVersion(HttpClient.Version.HTTP_2)
.build();
.withCircuitBreaker(circuitBreakerConfiguration)
.withRetry(new RetryConfiguration())
.withExecutor(Executors.newSingleThreadExecutor())
.withName("test")
.withVersion(HttpClient.Version.HTTP_2)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + 39873 + "/failure"))

View File

@@ -118,17 +118,19 @@ class ReplicatedJedisPoolTest {
void testCircuitBreakerOpen() {
CircuitBreakerConfiguration configuration = new CircuitBreakerConfiguration();
configuration.setFailureRateThreshold(50);
configuration.setRingBufferSizeInClosedState(2);
configuration.setSlidingWindowSize(2);
configuration.setSlidingWindowMinimumNumberOfCalls(2);
JedisPool master = mock(JedisPool.class);
JedisPool slaveOne = mock(JedisPool.class);
JedisPool slaveTwo = mock(JedisPool.class);
JedisPool master = mock(JedisPool.class);
JedisPool slaveOne = mock(JedisPool.class);
JedisPool slaveTwo = mock(JedisPool.class);
when(master.getResource()).thenReturn(null);
when(slaveOne.getResource()).thenThrow(new JedisException("Connection failed!"));
when(slaveTwo.getResource()).thenThrow(new JedisException("Also failed!"));
ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testCircuitBreakerOpen", master, Arrays.asList(slaveOne, slaveTwo), configuration);
ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testCircuitBreakerOpen", master,
Arrays.asList(slaveOne, slaveTwo), configuration);
replicatedJedisPool.getWriteResource();
when(master.getResource()).thenThrow(new JedisException("Master broken!"));
@@ -152,13 +154,14 @@ class ReplicatedJedisPoolTest {
void testCircuitBreakerHalfOpen() throws InterruptedException {
CircuitBreakerConfiguration configuration = new CircuitBreakerConfiguration();
configuration.setFailureRateThreshold(50);
configuration.setRingBufferSizeInClosedState(2);
configuration.setRingBufferSizeInHalfOpenState(1);
configuration.setSlidingWindowSize(2);
configuration.setSlidingWindowMinimumNumberOfCalls(2);
configuration.setPermittedNumberOfCallsInHalfOpenState(1);
configuration.setWaitDurationInOpenStateInSeconds(1);
JedisPool master = mock(JedisPool.class);
JedisPool slaveOne = mock(JedisPool.class);
JedisPool slaveTwo = mock(JedisPool.class);
JedisPool master = mock(JedisPool.class);
JedisPool slaveOne = mock(JedisPool.class);
JedisPool slaveTwo = mock(JedisPool.class);
when(master.getResource()).thenThrow(new JedisException("Master broken!"));
when(slaveOne.getResource()).thenThrow(new JedisException("Connection failed!"));

View File

@@ -20,6 +20,7 @@ import java.util.UUID;
import java.util.function.Consumer;
import org.mockito.MockingDetails;
import org.mockito.stubbing.Stubbing;
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
@@ -69,10 +70,13 @@ public class AccountsHelper {
});
when(mockAccountsManager.updateDeviceLastSeen(any(), any(), anyLong())).thenAnswer(answer -> {
answer.getArgument(1, Device.class).setLastSeen(answer.getArgument(2, Long.class));
return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {
});
return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {});
});
when(mockAccountsManager.updateDeviceAuthentication(any(), any(), any())).thenAnswer(answer -> {
answer.getArgument(1, Device.class).setAuthenticationCredentials(answer.getArgument(2, AuthenticationCredentials.class));
return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {});
});
}

View File

@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.tests.util;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
@@ -25,6 +26,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;

View File

@@ -1,33 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.util;
import static org.assertj.core.api.Assertions.assertThat;
import com.amazonaws.HttpMethod;
import java.net.URL;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.s3.UrlSigner;
class UrlSignerTest {
@Test
void testTransferAcceleration() {
UrlSigner signer = new UrlSigner("foo", "bar", "attachments-test");
URL url = signer.getPreSignedUrl(1234, HttpMethod.GET, false);
assertThat(url).hasHost("attachments-test.s3-accelerate.amazonaws.com");
}
@Test
void testTransferUnaccelerated() {
UrlSigner signer = new UrlSigner("foo", "bar", "attachments-test");
URL url = signer.getPreSignedUrl(1234, HttpMethod.GET, true);
assertThat(url).hasHost("s3.amazonaws.com");
}
}

View File

@@ -8,6 +8,7 @@ import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import java.time.Duration;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Predicate;
@@ -17,6 +18,8 @@ import static org.assertj.core.api.Assertions.assertThat;
public class UsernameGeneratorTest {
private static final Duration TTL = Duration.ofMinutes(5);
@ParameterizedTest(name = "[{index}]:{0} ({2})")
@MethodSource
public void nicknameValidation(String nickname, boolean valid, String testCaseName) {
@@ -31,6 +34,7 @@ public class UsernameGeneratorTest {
Arguments.of("ab\uD83D\uDC1B", false, "illegal character"),
Arguments.of("1test", false, "illegal start"),
Arguments.of("test#123", false, "illegal character"),
Arguments.of("test.123", false, "illegal character"),
Arguments.of("ab", false, "too short"),
Arguments.of("", false, ""),
Arguments.of("_123456789_123456789_123456789123", false, "33 characters"),
@@ -51,38 +55,38 @@ public class UsernameGeneratorTest {
static Stream<Arguments> nonStandardUsernames() {
return Stream.of(
Arguments.of("Test#123", false),
Arguments.of("test#-123", false),
Arguments.of("test#0", false),
Arguments.of("test#", false),
Arguments.of("test#1_00", false),
Arguments.of("Test.123", false),
Arguments.of("test.-123", false),
Arguments.of("test.0", false),
Arguments.of("test.", false),
Arguments.of("test.1_00", false),
Arguments.of("test#1", true),
Arguments.of("abc#1234", true)
Arguments.of("test.1", true),
Arguments.of("abc.1234", true)
);
}
@Test
public void zeroPadDiscriminators() {
final UsernameGenerator generator = new UsernameGenerator(4, 5, 1);
assertThat(generator.fromParts("test", 1)).isEqualTo("test#0001");
assertThat(generator.fromParts("test", 123)).isEqualTo("test#0123");
assertThat(generator.fromParts("test", 9999)).isEqualTo("test#9999");
assertThat(generator.fromParts("test", 99999)).isEqualTo("test#99999");
final UsernameGenerator generator = new UsernameGenerator(4, 5, 1, TTL);
assertThat(generator.fromParts("test", 1)).isEqualTo("test.0001");
assertThat(generator.fromParts("test", 123)).isEqualTo("test.0123");
assertThat(generator.fromParts("test", 9999)).isEqualTo("test.9999");
assertThat(generator.fromParts("test", 99999)).isEqualTo("test.99999");
}
@Test
public void expectedWidth() throws UsernameNotAvailableException {
String username = new UsernameGenerator(1, 6, 1).generateAvailableUsername("test", t -> true);
String username = new UsernameGenerator(1, 6, 1, TTL).generateAvailableUsername("test", t -> true);
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(10);
username = new UsernameGenerator(2, 6, 1).generateAvailableUsername("test", t -> true);
username = new UsernameGenerator(2, 6, 1, TTL).generateAvailableUsername("test", t -> true);
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(100);
}
@Test
public void expandDiscriminator() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10000));
int discriminator = extractDiscriminator(username);
assertThat(discriminator).isGreaterThanOrEqualTo(10000).isLessThan(100000);
@@ -90,7 +94,7 @@ public class UsernameGeneratorTest {
@Test
public void expandDiscriminatorToMax() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 100000));
int discriminator = extractDiscriminator(username);
assertThat(discriminator).isGreaterThanOrEqualTo(100000).isLessThan(1000000);
@@ -98,7 +102,7 @@ public class UsernameGeneratorTest {
@Test
public void exhaustDiscriminator() {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
Assertions.assertThrows(UsernameNotAvailableException.class, () -> {
// allow greater than our max width
ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 1000000));
@@ -107,7 +111,7 @@ public class UsernameGeneratorTest {
@Test
public void randomCoverageMinWidth() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final Set<Integer> seen = new HashSet<>();
for (int i = 0; i < 1000 && seen.size() < 9; i++) {
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", ignored -> true)));
@@ -120,7 +124,7 @@ public class UsernameGeneratorTest {
@Test
public void randomCoverageMidWidth() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final Set<Integer> seen = new HashSet<>();
for (int i = 0; i < 100000 && seen.size() < 90; i++) {
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10))));
@@ -136,6 +140,6 @@ public class UsernameGeneratorTest {
}
private static int extractDiscriminator(final String username) {
return Integer.parseInt(username.split(UsernameGenerator.SEPARATOR)[1]);
return Integer.parseInt(username.substring(username.indexOf(UsernameGenerator.SEPARATOR) + 1));
}
}

View File

@@ -0,0 +1,15 @@
package org.whispersystems.websocket;
/**
* Class containing constants and shared logic for handling stories.
* <p>
* In particular, it defines the way we interpret the X-Signal-Receive-Stories header
* which is used by both WebSockets and by the REST API.
*/
public class Stories {
public final static String X_SIGNAL_RECEIVE_STORIES = "X-Signal-Receive-Stories";
public static boolean parseReceiveStoriesHeader(String s) {
return "true".equals(s);
}
}

View File

@@ -35,13 +35,12 @@ public class WebSocketClient {
public WebSocketClient(Session session, RemoteEndpoint remoteEndpoint,
WebSocketMessageFactory messageFactory,
Map<Long, CompletableFuture<WebSocketResponseMessage>> pendingRequestMapper)
{
this.session = session;
this.remoteEndpoint = remoteEndpoint;
this.messageFactory = messageFactory;
Map<Long, CompletableFuture<WebSocketResponseMessage>> pendingRequestMapper) {
this.session = session;
this.remoteEndpoint = remoteEndpoint;
this.messageFactory = messageFactory;
this.pendingRequestMapper = pendingRequestMapper;
this.created = System.currentTimeMillis();
this.created = System.currentTimeMillis();
}
public CompletableFuture<WebSocketResponseMessage> sendRequest(String verb, String path,
@@ -92,6 +91,11 @@ public class WebSocketClient {
session.close(code, message);
}
public boolean shouldDeliverStories() {
String value = session.getUpgradeRequest().getHeader(Stories.X_SIGNAL_RECEIVE_STORIES);
return Stories.parseReceiveStoriesHeader(value);
}
public void hardDisconnectQuietly() {
try {
session.disconnect();

View File

@@ -653,12 +653,14 @@ class WebSocketResourceProviderTest {
assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("Sec-WebSocket-Key")).isFalse();
assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("User-Agent")).isTrue();
assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("X-Forwarded-For")).isTrue();
assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("X-Signal-Receive-Stories")).isTrue();
}
@Test
void testShouldIncludeRequestMessageHeader() {
assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader("X-Forwarded-For")).isFalse();
assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader("User-Agent")).isTrue();
assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader("X-Signal-Receive-Stories")).isTrue();
}
@Test