Compare commits

...

39 Commits

Author SHA1 Message Date
Ravi Khadiwala ae2d98750c Add SecureValueRecoveryBController 2025-07-11 13:39:18 -05:00
Ravi Khadiwala 7d41c1219b Add /v2/svr as an alternative name for /v2/backup 2025-07-11 13:39:18 -05:00
Ravi Khadiwala 65e1f1b3a9 Arm the RemoveOrphanedPreKeyPagesCommand 2025-07-11 13:26:15 -05:00
Ameya Lokare 437b823c84 Update to the latest version of the spam filter 2025-07-09 13:27:04 -07:00
ravi-signal c9f21d5970
Always read from new and old PQ prekey stores, add experiment to start writing to new prekey store 2025-07-09 09:17:17 -05:00
Ravi Khadiwala 80c11e7eda Handle 429s from play API and add subscription docs 2025-07-09 09:15:29 -05:00
Jon Chambers 0745cabc87 Explicitly use synchronous flush mode when clearing Redis databases between tests 2025-07-09 09:15:15 -05:00
Jon Chambers 3e80669f4e Reuse/extend lifetime of Redis singleton resources 2025-07-09 09:15:15 -05:00
Jon Chambers b81cd9ec61 Reuse Redis clusters for the duration of a test run 2025-07-09 09:15:15 -05:00
Jon Chambers da6ed94443 Reuse client resources for lifetime of Redis cluster 2025-07-09 09:15:15 -05:00
Ameya Lokare 96d41b3716 Update to the latest version of the spam filter 2025-07-07 09:16:48 -07:00
Ravi Khadiwala 7dddc4d759 fix an incorrect backup metric 2025-07-07 18:14:53 +02:00
Katherine a87690d817
Include Redis cluster and shard address in circuit breaker log 2025-07-07 12:12:44 -04:00
Ameya Lokare 18ef3da261 Update dependencies 2025-06-30 14:17:03 -07:00
Ameya Lokare f4698dd5b2 Update to the latest version of the spam filter 2025-06-27 12:07:45 -07:00
Adel Lahlou d4322a2ed4 Remove latency based 1:1 call routing 2025-06-27 12:06:43 -07:00
Jon Chambers 7260a9d5b4 Make FoundationDB versions available at runtime 2025-06-27 11:21:50 -04:00
Jon Chambers 12b4ceb4aa Configure FoundationDB service container's database via Docker, removing `fdbcli` dependency 2025-06-27 11:08:58 -04:00
Jon Chambers fa1cd5c263 Install the Maven-fetched FoundationDB client library on GitHub Actions runner 2025-06-27 11:06:04 -04:00
Jon Chambers f8da13912d Fetch the FoundationDB client library as a pre-package step rather than including it in version control 2025-06-27 11:04:53 -04:00
Jon Chambers a3b3bf86ba Add a note about the FoundationDB client library requirement to the README 2025-06-27 11:04:52 -04:00
Jon Chambers a99f7bb87d Add test dependencies for FoundationDB 2025-06-27 11:04:52 -04:00
Jon Chambers d6f14d02dd Add a FoundationDB service container for tests 2025-06-27 11:04:46 -04:00
Jon Chambers d18671eaf9 Add FoundationDB runtime dependencies 2025-06-26 12:13:09 -04:00
Jon Chambers 87c30d00e8 Store compressed envelopes at rest 2025-06-25 15:20:19 -04:00
Jon Chambers c8f45685b8 Expand envelopes on load from storage 2025-06-25 14:31:19 -04:00
Jon Chambers bb90d80d22 Add a utility for compressing/expanding envelopes 2025-06-25 14:31:19 -04:00
Jon Chambers dcc541f86e Add binary representation fields for service IDs/UUIDs 2025-06-25 14:31:19 -04:00
Ravi Khadiwala aaa36fd8f5 Add a crawler for orphaned prekey pages 2025-06-24 13:46:48 -05:00
Ravi Khadiwala 2bb14892af Add paged prekey store 2025-06-24 13:46:48 -05:00
Ameya Lokare 6d8701665e Update to the latest version of the spam filter 2025-06-24 11:46:11 -07:00
Katherine c2b8fdac0d
Only log for an unexpected error from the key transparency service 2025-06-24 14:45:53 -04:00
Katherine 059caa4c57
Implement key transparency endpoints using `simple-grpc` 2025-06-24 14:01:35 -04:00
Jon Chambers 51773f5709 Update to the latest version of the spam filter 2025-06-23 10:20:24 -04:00
Jon Chambers 483404a67f
Retire authenticated device getters 2025-06-23 10:10:30 -04:00
Jon Chambers 68b84dd56b Remove the PQ key check from `IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter`
All devices now have PQ keys
2025-06-23 08:58:15 -05:00
Jon Chambers 7709e1313c Update to the latest version of the spam filter 2025-06-23 09:50:45 -04:00
Jon Chambers c952baa672
Don't cache authenticated accounts in memory 2025-06-23 09:40:05 -04:00
Ravi Khadiwala 9dfe51eac4 Forbid linked devices from setting backup-ids 2025-06-18 11:07:52 -05:00
176 changed files with 4706 additions and 3889 deletions

View File

@ -12,6 +12,13 @@ jobs:
container: ubuntu:22.04
timeout-minutes: 20
services:
foundationdb:
# Note: this should generally match the version of the FoundationDB SERVER deployed in production; it's okay if
# it's a little behind the CLIENT version.
image: foundationdb/foundationdb:7.3.62
options: --name foundationdb
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
@ -26,6 +33,28 @@ jobs:
HOME: /root
- name: Install APT packages
# ca-certificates: required for AWS CRT client
run: apt update && apt install -y ca-certificates
run: |
# Add Docker's official GPG key:
apt update
apt install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
# Add Docker repository to apt sources:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
# ca-certificates: required for AWS CRT client
apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ca-certificates
- name: Configure FoundationDB database
run: docker exec foundationdb /usr/bin/fdbcli --exec 'configure new single memory'
- name: Download and install FoundationDB client
run: |
./mvnw -e -B -Pexclude-spam-filter clean prepare-package -DskipTests=true
cp service/target/jib-extra/usr/lib/libfdb_c.x86_64.so /usr/lib/libfdb_c.x86_64.so
ldconfig
- name: Build with Maven
run: ./mvnw -e -B verify
run: ./mvnw -e -B clean verify -DfoundationDb.serviceContainerName=foundationdb

View File

@ -11,6 +11,8 @@ https://signal.org/docs/
How to Build
------------
This project uses [FoundationDB](https://www.foundationdb.org/) and requires the FoundationDB client library to be installed on the host system. With that in place, the server can be built and tested with:
```shell script
$ ./mvnw clean test
```

65
pom.xml
View File

@ -37,46 +37,57 @@
</modules>
<properties>
<aws.sdk2.version>2.31.9</aws.sdk2.version>
<braintree.version>3.40.0</braintree.version>
<aws.sdk2.version>2.31.70</aws.sdk2.version>
<braintree.version>3.42.0</braintree.version>
<commons-csv.version>1.14.0</commons-csv.version>
<commons-io.version>2.18.0</commons-io.version>
<commons-io.version>2.19.0</commons-io.version>
<dropwizard.version>4.0.12</dropwizard.version>
<dropwizard-metrics-datadog.version>1.1.14</dropwizard-metrics-datadog.version>
<!-- can be updated to latest version with Dropwizard 5 (Jetty 12); will then need to disable telemetry -->
<dynamodblocal.version>2.2.1</dynamodblocal.version>
<google-cloud-libraries.version>26.57.0</google-cloud-libraries.version>
<grpc.version>1.70.0</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->
<gson.version>2.12.1</gson.version>
<!-- Note: when updating FoundationDB, also include a copy of `libfdb_c.so` from the FoundationDB release at
src/main/jib/usr/lib/libfdb_c.so. We use x86_64 builds without AVX instructions enabled (i.e. FoundationDB versions
with even-numbered patch versions). Also when updating FoundationDB, make sure to update the version of FoundationDB
used by GitHub Actions. -->
<foundationdb.version>7.3.62</foundationdb.version>
<foundationdb.api-version>730</foundationdb.api-version>
<foundationdb.client-library-sha256>bfed237b787fae3cde1222676e6bfbb0d218fc27bf9e903397a7a7aa96fb2d33</foundationdb.client-library-sha256>
<google-cloud-libraries.version>26.62.0</google-cloud-libraries.version>
<grpc.version>1.73.0</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->
<gson.version>2.13.1</gson.version>
<!-- several libraries (AWS, Google Cloud) use Apache http components transitively, and we need to align them -->
<httpcore.version>4.4.16</httpcore.version>
<httpclient.version>4.5.14</httpclient.version>
<jackson.version>2.18.3</jackson.version>
<jackson.version>2.19.1</jackson.version>
<junit-pioneer.version>2.3.0</junit-pioneer.version>
<jsr305.version>3.0.2</jsr305.version>
<kotlin.version>2.1.20</kotlin.version>
<kotlin.version>2.2.0</kotlin.version>
<!-- Logback 1.5.14+ has a null pointer bug: https://github.com/qos-ch/logback/issues/929. -->
<logback.version>1.5.13</logback.version>
<logback-access.version>2.0.5</logback-access.version>
<lettuce.version>6.5.5.RELEASE</lettuce.version>
<libphonenumber.version>9.0.2</libphonenumber.version>
<logstash.logback.version>7.3</logstash.logback.version>
<log4j-bom.version>2.24.3</log4j-bom.version>
<logback-access-common.version>2.0.5</logback-access-common.version>
<lettuce.version>6.7.1.RELEASE</lettuce.version>
<libphonenumber.version>9.0.8</libphonenumber.version>
<logstash.logback.version>8.1</logstash.logback.version>
<log4j-bom.version>2.25.0</log4j-bom.version>
<luajava.version>3.5.0</luajava.version>
<micrometer.version>1.14.5</micrometer.version>
<netty.version>4.1.119.Final</netty.version>
<micrometer.version>1.15.1</micrometer.version>
<netty.version>4.1.122.Final</netty.version>
<!-- Must be less than or equal to the value from Google libraries-bom which controls the protobuf runtime version.
See https://protobuf.dev/support/cross-version-runtime-guarantee/. -->
<protoc.version>4.29.4</protoc.version>
<pushy.version>0.15.4</pushy.version>
<reactive.grpc.version>1.2.4</reactive.grpc.version>
<reactor-bom.version>2024.0.4</reactor-bom.version> <!-- 3.7.4, see https://github.com/reactor/reactor#bom-versioning-scheme -->
<reactor-bom.version>2024.0.7</reactor-bom.version> <!-- 3.7.4, see https://github.com/reactor/reactor#bom-versioning-scheme -->
<resilience4j.version>2.3.0</resilience4j.version>
<semver4j.version>3.1.0</semver4j.version>
<simple-grpc.version>0.1.0</simple-grpc.version>
<slf4j.version>2.0.17</slf4j.version>
<stripe.version>23.10.0</stripe.version>
<swagger.version>2.2.27</swagger.version>
<swagger.version>2.2.31</swagger.version>
<testcontainers.version>1.21.2</testcontainers.version>
<!-- image to use in tests that run localstack via docker. -->
<localstack.image>localstack/localstack:3.5.0</localstack.image>
<!-- eclipse-temurin:21.0.6_7-jre-jammy (note: always use the multi-arch manifest *LIST* here) -->
<docker.image.sha256>02fc89fa8766a9ba221e69225f8d1c10bb91885ddbd3c112448e23488ba40ab6</docker.image.sha256>
@ -210,6 +221,11 @@
<artifactId>dropwizard-metrics-datadog</artifactId>
<version>${dropwizard-metrics-datadog.version}</version>
</dependency>
<dependency>
<groupId>org.foundationdb</groupId>
<artifactId>fdb-java</artifactId>
<version>${foundationdb.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
@ -309,7 +325,20 @@
<dependency>
<groupId>ch.qos.logback.access</groupId>
<artifactId>logback-access-common</artifactId>
<version>${logback-access.version}</version>
<version>${logback-access-common.version}</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>earth.adi</groupId>
<artifactId>testcontainers-foundationdb</artifactId>
<version>1.1.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>

View File

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

View File

@ -138,6 +138,8 @@ dynamoDbTables:
tableName: Example_EC_Signed_Pre_Keys
pqKeys:
tableName: Example_PQ_Keys
pagedPqKeys:
tableName: Example_PQ_Paged_Keys
pqLastResortKeys:
tableName: Example_PQ_Last_Resort_Keys
messages:
@ -174,6 +176,10 @@ dynamoDbTables:
verificationSessions:
tableName: Example_VerificationSessions
pagedSingleUseKEMPreKeyStore:
bucket: preKeyBucket # S3 Bucket name
region: us-west-2 # AWS region
cacheCluster: # Redis server configuration for cache cluster
configurationUri: redis://redis.example.com:6379/
@ -219,6 +225,34 @@ svr2:
AAAAAAAAAAAAAAAAAAAA
-----END CERTIFICATE-----
svrb:
uri: svrb.example.com
userAuthenticationTokenSharedSecret: secret://svrb.userAuthenticationTokenSharedSecret
userIdTokenSharedSecret: secret://svrb.userIdTokenSharedSecret
svrCaCertificates:
- |
-----BEGIN CERTIFICATE-----
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
AAAAAAAAAAAAAAAAAAAA
-----END CERTIFICATE-----
messageCache: # Redis server configuration for message store cache
persistDelayMinutes: 1
cluster:

View File

@ -51,6 +51,13 @@
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-jaxrs2-jakarta</artifactId>
<version>${swagger.version}</version>
<exclusions>
<!-- conflicts with jackson-dataformat-yaml -->
<exclusion>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
@ -351,6 +358,11 @@
<artifactId>reactor-grpc-stub</artifactId>
</dependency>
<dependency>
<groupId>org.foundationdb</groupId>
<artifactId>fdb-java</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>apache-client</artifactId>
@ -485,6 +497,24 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>localstack</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>earth.adi</groupId>
<artifactId>testcontainers-foundationdb</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
@ -520,6 +550,28 @@
<id>exclude-spam-filter</id>
<build>
<plugins>
<plugin>
<groupId>io.github.download-maven-plugin</groupId>
<artifactId>download-maven-plugin</artifactId>
<version>2.0.0</version>
<executions>
<execution>
<id>install-foundationdb-client-library</id>
<phase>prepare-package</phase>
<goals>
<goal>wget</goal>
</goals>
</execution>
</executions>
<configuration>
<url>https://github.com/apple/foundationdb/releases/download/${foundationdb.version}/libfdb_c.x86_64.so</url>
<outputDirectory>${project.build.directory}/jib-extra/usr/lib</outputDirectory>
<sha256>${foundationdb.client-library-sha256}</sha256>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
@ -641,6 +693,9 @@
<includes>*.yml</includes>
<into>/usr/share/signal/</into>
</path>
<path>
<from>${project.build.directory}/jib-extra</from>
</path>
</paths>
</extraDirectories>
</configuration>
@ -712,6 +767,9 @@
<configuration>
<!-- add-opens: work around PATCH not being a supported method on HttpUrlConnection -->
<argLine>-javaagent:${org.mockito:mockito-core:jar} --add-opens=java.base/java.net=ALL-UNNAMED</argLine>
<systemPropertyVariables>
<localstackImage>${localstack.image}</localstackImage>
</systemPropertyVariables>
</configuration>
</plugin>

View File

@ -0,0 +1,20 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
public class FoundationDbVersion {
private static final String VERSION = "${foundationdb.version}";
private static final int API_VERSION = ${foundationdb.api-version};
public static String getFoundationDbVersion() {
return VERSION;
}
public static int getFoundationDbApiVersion() {
return API_VERSION;
}
}

View File

@ -45,13 +45,14 @@ import org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalit
import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration;
import org.whispersystems.textsecuregcm.configuration.NoiseTunnelConfiguration;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.PagedSingleUseKEMPreKeyStoreConfiguration;
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.RegistrationServiceClientFactory;
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration;
import org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
import org.whispersystems.textsecuregcm.configuration.ShortCodeExpanderConfiguration;
import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
@ -155,7 +156,12 @@ public class WhisperServerConfiguration extends Configuration {
@NotNull
@Valid
@JsonProperty
private SecureValueRecovery2Configuration svr2;
private SecureValueRecoveryConfiguration svr2;
@NotNull
@Valid
@JsonProperty
private SecureValueRecoveryConfiguration svrb;
@NotNull
@Valid
@ -257,6 +263,11 @@ public class WhisperServerConfiguration extends Configuration {
@NotNull
private OneTimeDonationConfiguration oneTimeDonations;
@Valid
@JsonProperty
@NotNull
private PagedSingleUseKEMPreKeyStoreConfiguration pagedSingleUseKEMPreKeyStore;
@Valid
@NotNull
@JsonProperty
@ -383,10 +394,14 @@ public class WhisperServerConfiguration extends Configuration {
return pubsub;
}
public SecureValueRecovery2Configuration getSvr2Configuration() {
public SecureValueRecoveryConfiguration getSvr2Configuration() {
return svr2;
}
public SecureValueRecoveryConfiguration getSvrbConfiguration() {
return svrb;
}
public DirectoryV2Configuration getDirectoryV2Configuration() {
return directoryV2;
}
@ -478,6 +493,10 @@ public class WhisperServerConfiguration extends Configuration {
return oneTimeDonations;
}
public PagedSingleUseKEMPreKeyStoreConfiguration getPagedSingleUseKEMPreKeyStore() {
return pagedSingleUseKEMPreKeyStore;
}
public ReportMessageConfiguration getReportMessageConfiguration() {
return reportMessage;
}

View File

@ -85,7 +85,6 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator
import org.whispersystems.textsecuregcm.auth.IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter;
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener;
import org.whispersystems.textsecuregcm.auth.grpc.ProhibitAuthenticationInterceptor;
import org.whispersystems.textsecuregcm.auth.grpc.RequireAuthenticationInterceptor;
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
@ -125,6 +124,7 @@ import org.whispersystems.textsecuregcm.controllers.RegistrationController;
import org.whispersystems.textsecuregcm.controllers.RemoteConfigController;
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller;
import org.whispersystems.textsecuregcm.controllers.SecureValueRecoveryBController;
import org.whispersystems.textsecuregcm.controllers.StickerController;
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
import org.whispersystems.textsecuregcm.controllers.VerificationController;
@ -212,7 +212,6 @@ import org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker;
import org.whispersystems.textsecuregcm.spam.SpamChecker;
import org.whispersystems.textsecuregcm.spam.SpamFilter;
import org.whispersystems.textsecuregcm.storage.AccountLockManager;
import org.whispersystems.textsecuregcm.storage.AccountPrincipalSupplier;
import org.whispersystems.textsecuregcm.storage.Accounts;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
@ -227,6 +226,7 @@ import org.whispersystems.textsecuregcm.storage.MessagesCache;
import org.whispersystems.textsecuregcm.storage.MessagesDynamoDb;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.PagedSingleUseKEMPreKeyStore;
import org.whispersystems.textsecuregcm.storage.PersistentTimer;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.Profiles;
@ -237,8 +237,12 @@ import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.RemoteConfigs;
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
import org.whispersystems.textsecuregcm.storage.RepeatedUseECSignedPreKeyStore;
import org.whispersystems.textsecuregcm.storage.RepeatedUseKEMSignedPreKeyStore;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.SingleUseECPreKeyStore;
import org.whispersystems.textsecuregcm.storage.SingleUseKEMPreKeyStore;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.Subscriptions;
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
@ -276,6 +280,7 @@ import org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand;
import org.whispersystems.textsecuregcm.workers.RemoveExpiredBackupsCommand;
import org.whispersystems.textsecuregcm.workers.RemoveExpiredLinkedDevicesCommand;
import org.whispersystems.textsecuregcm.workers.RemoveExpiredUsernameHoldsCommand;
import org.whispersystems.textsecuregcm.workers.RemoveOrphanedPreKeyPagesCommand;
import org.whispersystems.textsecuregcm.workers.ScheduledApnPushNotificationSenderServiceCommand;
import org.whispersystems.textsecuregcm.workers.ServerVersionCommand;
import org.whispersystems.textsecuregcm.workers.SetRequestLoggingEnabledTask;
@ -329,6 +334,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
bootstrap.addCommand(new RemoveExpiredAccountsCommand(Clock.systemUTC()));
bootstrap.addCommand(new RemoveExpiredUsernameHoldsCommand(Clock.systemUTC()));
bootstrap.addCommand(new RemoveExpiredBackupsCommand(Clock.systemUTC()));
bootstrap.addCommand(new RemoveOrphanedPreKeyPagesCommand(Clock.systemUTC()));
bootstrap.addCommand(new BackupMetricsCommand(Clock.systemUTC()));
bootstrap.addCommand(new BackupUsageRecalculationCommand());
bootstrap.addCommand(new RemoveExpiredLinkedDevicesCommand());
@ -365,6 +371,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
MetricsUtil.configureRegistries(config, environment, dynamicConfigurationManager);
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);
if (config.getServerFactory() instanceof DefaultServerFactory defaultServerFactory) {
defaultServerFactory.getApplicationConnectors()
.forEach(connectorFactory -> {
@ -427,13 +435,22 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
config.getDynamoDbTables().getProfiles().getTableName());
S3AsyncClient asyncKeysS3Client = S3AsyncClient.builder()
.credentialsProvider(awsCredentialsProvider)
.region(Region.of(config.getPagedSingleUseKEMPreKeyStore().region()))
.build();
KeysManager keysManager = new KeysManager(
dynamoDbAsyncClient,
config.getDynamoDbTables().getEcKeys().getTableName(),
config.getDynamoDbTables().getKemKeys().getTableName(),
config.getDynamoDbTables().getEcSignedPreKeys().getTableName(),
config.getDynamoDbTables().getKemLastResortKeys().getTableName()
);
new SingleUseECPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getEcKeys().getTableName()),
new SingleUseKEMPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getKemKeys().getTableName()),
new PagedSingleUseKEMPreKeyStore(
dynamoDbAsyncClient,
asyncKeysS3Client,
config.getDynamoDbTables().getPagedKemKeys().getTableName(),
config.getPagedSingleUseKEMPreKeyStore().bucket()),
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getEcSignedPreKeys().getTableName()),
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getKemLastResortKeys().getTableName()),
experimentEnrollmentManager);
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
config.getDynamoDbTables().getMessages().getTableName(),
config.getDynamoDbTables().getMessages().getExpiration(),
@ -556,8 +573,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.maxThreads(2)
.minThreads(2)
.build();
ExecutorService keyTransparencyCallbackExecutor = environment.lifecycle()
.virtualExecutorService(name(getClass(), "keyTransparency-%d"));
ExecutorService googlePlayBillingExecutor = environment.lifecycle()
.virtualExecutorService(name(getClass(), "googlePlayBilling-%d"));
ExecutorService appleAppStoreExecutor = environment.lifecycle()
@ -594,9 +609,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getPaymentsServiceConfiguration());
ExternalServiceCredentialsGenerator svr2CredentialsGenerator = SecureValueRecovery2Controller.credentialsGenerator(
config.getSvr2Configuration());
ExternalServiceCredentialsGenerator svrbCredentialsGenerator = SecureValueRecoveryBController.credentialsGenerator(
config.getSvrbConfiguration());
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(
dynamicConfigurationManager);
RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords);
UsernameHashZkProofVerifier usernameHashZkProofVerifier = new UsernameHashZkProofVerifier();
@ -608,8 +623,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getKeyTransparencyServiceConfiguration().port(),
config.getKeyTransparencyServiceConfiguration().tlsCertificate(),
config.getKeyTransparencyServiceConfiguration().clientCertificate(),
config.getKeyTransparencyServiceConfiguration().clientPrivateKey().value(),
keyTransparencyCallbackExecutor);
config.getKeyTransparencyServiceConfiguration().clientPrivateKey().value());
SecureValueRecovery2Client secureValueRecovery2Client = new SecureValueRecovery2Client(svr2CredentialsGenerator,
secureValueRecovery2ServiceExecutor, secureValueRecoveryServiceRetryExecutor, config.getSvr2Configuration());
SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator,
@ -980,23 +994,19 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(MultiRecipientMessageProvider.class);
environment.jersey().register(new AuthDynamicFeature(accountAuthFilter));
environment.jersey().register(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class));
environment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager,
disconnectionRequestManager));
environment.jersey().register(new TimestampResponseFilter());
///
WebSocketEnvironment<AuthenticatedDevice> webSocketEnvironment = new WebSocketEnvironment<>(environment,
config.getWebSocketConfiguration(), Duration.ofMillis(90000));
webSocketEnvironment.jersey().register(new VirtualExecutorServiceProvider("managed-async-websocket-virtual-thread-"));
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator, new AccountPrincipalSupplier(accountsManager)));
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));
webSocketEnvironment.setAuthenticatedWebSocketUpgradeFilter(new IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter(
keysManager, config.idlePrimaryDeviceReminderConfiguration().minIdleDuration(), Clock.systemUTC()));
config.idlePrimaryDeviceReminderConfiguration().minIdleDuration(), Clock.systemUTC()));
webSocketEnvironment.setConnectListener(
new AuthenticatedConnectListener(receiptSender, messagesManager, messageMetrics, pushNotificationManager,
new AuthenticatedConnectListener(accountsManager, receiptSender, messagesManager, messageMetrics, pushNotificationManager,
pushNotificationScheduler, webSocketConnectionEventManager, websocketScheduledExecutor,
messageDeliveryScheduler, clientReleaseManager, messageDeliveryLoopMonitor, experimentEnrollmentManager));
webSocketEnvironment.jersey()
.register(new WebsocketRefreshApplicationEventListener(accountsManager, disconnectionRequestManager));
webSocketEnvironment.jersey().register(new RateLimitByIpFilter(rateLimiters));
webSocketEnvironment.jersey().register(new RequestStatisticsFilter(TrafficSource.WEBSOCKET));
webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);
@ -1083,15 +1093,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
registrationLockVerificationManager, rateLimiters),
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, tusAttachmentGenerator,
experimentEnrollmentManager),
new ArchiveController(backupAuthManager, backupManager, backupMetrics),
new ArchiveController(accountsManager, backupAuthManager, backupManager, backupMetrics),
new CallRoutingControllerV2(rateLimiters, cloudflareTurnCredentialsManager),
new CallLinkController(rateLimiters, callingGenericZkSecretParams),
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate(),
new CertificateController(accountsManager, new CertificateGenerator(config.getDeliveryCertificate().certificate(),
config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()),
zkAuthOperations, callingGenericZkSecretParams, clock),
new ChallengeController(rateLimitChallengeManager, challengeConstraintChecker),
new ChallengeController(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
new DeviceController(accountsManager, clientPublicKeysManager, rateLimiters, persistentTimer, config.getMaxDevices()),
new DeviceCheckController(clock, backupAuthManager, appleDeviceCheckManager, rateLimiters,
new DeviceCheckController(clock, accountsManager, backupAuthManager, appleDeviceCheckManager, rateLimiters,
config.getDeviceCheck().backupRedemptionLevel(),
config.getDeviceCheck().backupRedemptionDuration()),
new DirectoryV2Controller(directoryV2CredentialsGenerator),
@ -1114,6 +1124,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig(), clock),
new SecureStorageController(storageCredentialsGenerator),
new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager),
new SecureValueRecoveryBController(svrbCredentialsGenerator),
new StickerController(rateLimiters, config.getCdnConfiguration().credentials().accessKeyId().value(),
config.getCdnConfiguration().credentials().secretAccessKey().value(), config.getCdnConfiguration().region(),
config.getCdnConfiguration().bucket()),
@ -1140,8 +1151,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
WebSocketEnvironment<AuthenticatedDevice> provisioningEnvironment = new WebSocketEnvironment<>(environment,
webSocketEnvironment.getRequestLog(), Duration.ofMillis(60000));
provisioningEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager,
disconnectionRequestManager));
provisioningEnvironment.setConnectListener(new ProvisioningConnectListener(provisioningManager, provisioningWebsocketTimeoutExecutor, Duration.ofSeconds(90)));
provisioningEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET, clientReleaseManager));
provisioningEnvironment.jersey().register(new KeepAliveController(webSocketConnectionEventManager));

View File

@ -1,16 +0,0 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Device;
public interface AccountAndAuthenticatedDeviceHolder {
Account getAccount();
Device getAuthenticatedDevice();
}

View File

@ -15,10 +15,12 @@ import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tags;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
@ -112,7 +114,9 @@ public class AccountAuthenticator implements Authenticator<BasicCredentials, Aut
device.get(),
SaltedTokenHash.generateFor(basicCredentials.getPassword())); // new credentials have current version
}
return Optional.of(new AuthenticatedDevice(authenticatedAccount, device.get()));
return Optional.of(new AuthenticatedDevice(authenticatedAccount.getIdentifier(IdentityType.ACI),
device.get().getId(),
Instant.ofEpochMilli(authenticatedAccount.getPrimaryDevice().getLastSeen())));
} else {
failureReason = "incorrectPassword";
return Optional.empty();

View File

@ -6,31 +6,12 @@
package org.whispersystems.textsecuregcm.auth;
import java.security.Principal;
import java.time.Instant;
import java.util.UUID;
import javax.security.auth.Subject;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Device;
public class AuthenticatedDevice implements Principal, AccountAndAuthenticatedDeviceHolder {
private final Account account;
private final Device device;
public AuthenticatedDevice(final Account account, final Device device) {
this.account = account;
this.device = device;
}
@Override
public Account getAccount() {
return account;
}
@Override
public Device getAuthenticatedDevice() {
return device;
}
// Principal implementation
public record AuthenticatedDevice(UUID accountIdentifier, byte deviceId, Instant primaryDeviceLastSeen)
implements Principal {
@Override
public String getName() {

View File

@ -31,9 +31,9 @@ public class CertificateGenerator {
this.serverCertificate = ServerCertificate.parseFrom(serverCertificate);
}
public byte[] createFor(Account account, Device device, boolean includeE164) throws InvalidKeyException {
public byte[] createFor(final Account account, final byte deviceId, boolean includeE164) throws InvalidKeyException {
SenderCertificate.Certificate.Builder builder = SenderCertificate.Certificate.newBuilder()
.setSenderDevice(Math.toIntExact(device.getId()))
.setSenderDevice(Math.toIntExact(deviceId))
.setExpires(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(expiresDays))
.setIdentityKey(ByteString.copyFrom(account.getIdentityKey(IdentityType.ACI).serialize()))
.setSigner(serverCertificate)

View File

@ -1,44 +0,0 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import jakarta.ws.rs.core.SecurityContext;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.glassfish.jersey.server.ContainerRequest;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Device;
class ContainerRequestUtil {
/**
* A read-only subset of the authenticated Account object, to enforce that filter-based consumers do not perform
* account modifying operations.
*/
record AccountInfo(UUID accountId, String e164, Set<Byte> deviceIds) {
static AccountInfo fromAccount(final Account account) {
return new AccountInfo(
account.getUuid(),
account.getNumber(),
account.getDevices().stream().map(Device::getId).collect(Collectors.toSet()));
}
}
static Optional<AccountInfo> getAuthenticatedAccount(final ContainerRequest request) {
return Optional.ofNullable(request.getSecurityContext())
.map(SecurityContext::getUserPrincipal)
.map(principal -> {
if (principal instanceof AccountAndAuthenticatedDeviceHolder aaadh) {
return aaadh.getAccount();
}
return null;
})
.map(AccountInfo::fromAccount);
}
}

View File

@ -8,23 +8,19 @@ package org.whispersystems.textsecuregcm.auth;
import com.google.common.annotations.VisibleForTesting;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;
import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.KeysManager;
import org.whispersystems.websocket.ReusableAuth;
import org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;
import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;
public class IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter implements
AuthenticatedWebSocketUpgradeFilter<AuthenticatedDevice> {
private final KeysManager keysManager;
private final Duration minIdleDuration;
private final Clock clock;
@ -34,49 +30,26 @@ public class IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter implements
@VisibleForTesting
static final String IDLE_PRIMARY_DEVICE_ALERT = "idle-primary-device";
@VisibleForTesting
static final String CRITICAL_IDLE_PRIMARY_DEVICE_ALERT = "critical-idle-primary-device";
@VisibleForTesting
static final Duration PQ_KEY_CHECK_THRESHOLD = Duration.ofDays(120);
private static final Counter IDLE_PRIMARY_WARNING_COUNTER = Metrics.counter(
MetricsUtil.name(IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter.class, "idlePrimaryDeviceWarning"),
"critical", "false");
private static final Counter CRITICAL_IDLE_PRIMARY_WARNING_COUNTER = Metrics.counter(
MetricsUtil.name(IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter.class, "idlePrimaryDeviceWarning"),
"critical", "true");
public IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter(final KeysManager keysManager,
final Duration minIdleDuration,
final Clock clock) {
this.keysManager = keysManager;
public IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter(final Duration minIdleDuration, final Clock clock) {
this.minIdleDuration = minIdleDuration;
this.clock = clock;
}
@Override
public void handleAuthentication(final ReusableAuth<AuthenticatedDevice> authenticated,
public void handleAuthentication(final Optional<AuthenticatedDevice> authenticated,
final JettyServerUpgradeRequest request,
final JettyServerUpgradeResponse response) {
// No action needed if the connection is unauthenticated (in which case we don't know when we've last seen the
// primary device) or if the authenticated device IS the primary device
authenticated.ref()
.filter(authenticatedDevice -> !authenticatedDevice.getAuthenticatedDevice().isPrimary())
authenticated
.filter(authenticatedDevice -> authenticatedDevice.deviceId() != Device.PRIMARY_ID)
.ifPresent(authenticatedDevice -> {
final Instant primaryDeviceLastSeen =
Instant.ofEpochMilli(authenticatedDevice.getAccount().getPrimaryDevice().getLastSeen());
if (primaryDeviceLastSeen.isBefore(clock.instant().minus(PQ_KEY_CHECK_THRESHOLD)) &&
keysManager.getLastResort(authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI), Device.PRIMARY_ID)
.join().isEmpty()) {
response.addHeader(ALERT_HEADER, CRITICAL_IDLE_PRIMARY_DEVICE_ALERT);
CRITICAL_IDLE_PRIMARY_WARNING_COUNTER.increment();
} else if (primaryDeviceLastSeen.isBefore(clock.instant().minus(minIdleDuration))) {
if (authenticatedDevice.primaryDeviceLastSeen().isBefore(clock.instant().minus(minIdleDuration))) {
response.addHeader(ALERT_HEADER, IDLE_PRIMARY_DEVICE_ALERT);
IDLE_PRIMARY_WARNING_COUNTER.increment();
}

View File

@ -1,96 +0,0 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.glassfish.jersey.server.ContainerRequest;
import org.glassfish.jersey.server.monitoring.RequestEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.Pair;
/**
* This {@link WebsocketRefreshRequirementProvider} observes intra-request changes in devices linked to an
* {@link Account} and triggers a WebSocket refresh if that set changes. If a change in linked devices is observed, then
* any active WebSocket connections for the account must be closed in order for clients to get a refreshed
* {@link io.dropwizard.auth.Auth} object with a current device list.
*
* @see AuthenticatedDevice
*/
public class LinkedDeviceRefreshRequirementProvider implements WebsocketRefreshRequirementProvider {
private final AccountsManager accountsManager;
private static final Logger logger = LoggerFactory.getLogger(LinkedDeviceRefreshRequirementProvider.class);
private static final String ACCOUNT_UUID = LinkedDeviceRefreshRequirementProvider.class.getName() + ".accountUuid";
private static final String LINKED_DEVICE_IDS = LinkedDeviceRefreshRequirementProvider.class.getName() + ".deviceIds";
public LinkedDeviceRefreshRequirementProvider(final AccountsManager accountsManager) {
this.accountsManager = accountsManager;
}
@Override
public void handleRequestFiltered(final RequestEvent requestEvent) {
if (requestEvent.getUriInfo().getMatchedResourceMethod().getInvocable().getHandlingMethod().getAnnotation(
ChangesLinkedDevices.class) != null) {
// The authenticated principal, if any, will be available after filters have run. Now that the account is known,
// capture a snapshot of the account's linked devices before carrying out the requests business logic.
ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest())
.ifPresent(account -> setAccount(requestEvent.getContainerRequest(), account));
}
}
public static void setAccount(final ContainerRequest containerRequest, final Account account) {
setAccount(containerRequest, ContainerRequestUtil.AccountInfo.fromAccount(account));
}
private static void setAccount(final ContainerRequest containerRequest, final ContainerRequestUtil.AccountInfo info) {
containerRequest.setProperty(ACCOUNT_UUID, info.accountId());
containerRequest.setProperty(LINKED_DEVICE_IDS, info.deviceIds());
}
@Override
public List<Pair<UUID, Byte>> handleRequestFinished(final RequestEvent requestEvent) {
// Now that the request is finished, check whether the set of linked devices has changed. If the value did change or
// if a devices was added or removed, all devices must disconnect and reauthenticate.
if (requestEvent.getContainerRequest().getProperty(LINKED_DEVICE_IDS) != null) {
@SuppressWarnings("unchecked") final Set<Byte> initialLinkedDeviceIds =
(Set<Byte>) requestEvent.getContainerRequest().getProperty(LINKED_DEVICE_IDS);
return accountsManager.getByAccountIdentifier((UUID) requestEvent.getContainerRequest().getProperty(ACCOUNT_UUID))
.map(ContainerRequestUtil.AccountInfo::fromAccount)
.map(accountInfo -> {
final Set<Byte> deviceIdsToDisplace;
final Set<Byte> currentLinkedDeviceIds = accountInfo.deviceIds();
if (!initialLinkedDeviceIds.equals(currentLinkedDeviceIds)) {
deviceIdsToDisplace = new HashSet<>(initialLinkedDeviceIds);
deviceIdsToDisplace.addAll(currentLinkedDeviceIds);
} else {
deviceIdsToDisplace = Collections.emptySet();
}
return deviceIdsToDisplace.stream()
.map(deviceId -> new Pair<>(accountInfo.accountId(), deviceId))
.collect(Collectors.toList());
}).orElseGet(() -> {
logger.error("Request had account, but it is no longer present");
return Collections.emptyList();
});
} else {
return Collections.emptyList();
}
}
}

View File

@ -1,56 +0,0 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import org.glassfish.jersey.server.monitoring.RequestEvent;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.Pair;
public class PhoneNumberChangeRefreshRequirementProvider implements WebsocketRefreshRequirementProvider {
private static final String ACCOUNT_UUID =
PhoneNumberChangeRefreshRequirementProvider.class.getName() + ".accountUuid";
private static final String INITIAL_NUMBER_KEY =
PhoneNumberChangeRefreshRequirementProvider.class.getName() + ".initialNumber";
private final AccountsManager accountsManager;
public PhoneNumberChangeRefreshRequirementProvider(final AccountsManager accountsManager) {
this.accountsManager = accountsManager;
}
@Override
public void handleRequestFiltered(final RequestEvent requestEvent) {
if (requestEvent.getUriInfo().getMatchedResourceMethod().getInvocable().getHandlingMethod()
.getAnnotation(ChangesPhoneNumber.class) == null) {
return;
}
ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest())
.ifPresent(account -> {
requestEvent.getContainerRequest().setProperty(INITIAL_NUMBER_KEY, account.e164());
requestEvent.getContainerRequest().setProperty(ACCOUNT_UUID, account.accountId());
});
}
@Override
public List<Pair<UUID, Byte>> handleRequestFinished(final RequestEvent requestEvent) {
final String initialNumber = (String) requestEvent.getContainerRequest().getProperty(INITIAL_NUMBER_KEY);
if (initialNumber == null) {
return Collections.emptyList();
}
return accountsManager.getByAccountIdentifier((UUID) requestEvent.getContainerRequest().getProperty(ACCOUNT_UUID))
.filter(account -> !initialNumber.equals(account.getNumber()))
.map(account -> account.getDevices().stream()
.map(device -> new Pair<>(account.getUuid(), device.getId()))
.collect(Collectors.toList()))
.orElse(Collections.emptyList());
}
}

View File

@ -1,38 +0,0 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import org.glassfish.jersey.server.monitoring.ApplicationEvent;
import org.glassfish.jersey.server.monitoring.ApplicationEventListener;
import org.glassfish.jersey.server.monitoring.RequestEvent;
import org.glassfish.jersey.server.monitoring.RequestEventListener;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
/**
* Delegates request events to a listener that watches for intra-request changes that require websocket refreshes
*/
public class WebsocketRefreshApplicationEventListener implements ApplicationEventListener {
private final WebsocketRefreshRequestEventListener websocketRefreshRequestEventListener;
public WebsocketRefreshApplicationEventListener(final AccountsManager accountsManager,
final DisconnectionRequestManager disconnectionRequestManager) {
this.websocketRefreshRequestEventListener = new WebsocketRefreshRequestEventListener(
disconnectionRequestManager,
new LinkedDeviceRefreshRequirementProvider(accountsManager),
new PhoneNumberChangeRefreshRequirementProvider(accountsManager));
}
@Override
public void onEvent(final ApplicationEvent event) {
}
@Override
public RequestEventListener onRequest(final RequestEvent requestEvent) {
return websocketRefreshRequestEventListener;
}
}

View File

@ -1,74 +0,0 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import jakarta.ws.rs.container.ResourceInfo;
import jakarta.ws.rs.core.Context;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.glassfish.jersey.server.monitoring.RequestEvent;
import org.glassfish.jersey.server.monitoring.RequestEvent.Type;
import org.glassfish.jersey.server.monitoring.RequestEventListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class WebsocketRefreshRequestEventListener implements RequestEventListener {
private final DisconnectionRequestManager disconnectionRequestManager;
private final WebsocketRefreshRequirementProvider[] providers;
private static final Counter DISPLACED_ACCOUNTS = Metrics.counter(
name(WebsocketRefreshRequestEventListener.class, "displacedAccounts"));
private static final Counter DISPLACED_DEVICES = Metrics.counter(
name(WebsocketRefreshRequestEventListener.class, "displacedDevices"));
private static final Logger logger = LoggerFactory.getLogger(WebsocketRefreshRequestEventListener.class);
public WebsocketRefreshRequestEventListener(
final DisconnectionRequestManager disconnectionRequestManager,
final WebsocketRefreshRequirementProvider... providers) {
this.disconnectionRequestManager = disconnectionRequestManager;
this.providers = providers;
}
@Context
private ResourceInfo resourceInfo;
@Override
public void onEvent(final RequestEvent event) {
if (event.getType() == Type.REQUEST_FILTERED) {
for (final WebsocketRefreshRequirementProvider provider : providers) {
provider.handleRequestFiltered(event);
}
} else if (event.getType() == Type.FINISHED) {
final AtomicInteger displacedDevices = new AtomicInteger(0);
Arrays.stream(providers)
.flatMap(provider -> provider.handleRequestFinished(event).stream())
.distinct()
.forEach(pair -> {
try {
displacedDevices.incrementAndGet();
disconnectionRequestManager.requestDisconnection(pair.first(), List.of(pair.second()));
} catch (final Exception e) {
logger.error("Could not displace device presence", e);
}
});
if (displacedDevices.get() > 0) {
DISPLACED_ACCOUNTS.increment();
DISPLACED_DEVICES.increment(displacedDevices.get());
}
}
}
}

View File

@ -1,34 +0,0 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import java.util.List;
import java.util.UUID;
import org.glassfish.jersey.server.monitoring.RequestEvent;
import org.whispersystems.textsecuregcm.util.Pair;
/**
* A websocket refresh requirement provider watches for intra-request changes (e.g. to authentication status) that
* require a websocket refresh.
*/
public interface WebsocketRefreshRequirementProvider {
/**
* Processes a request after filters have run and the request has been mapped to a destination controller.
*
* @param requestEvent the request event to observe
*/
void handleRequestFiltered(RequestEvent requestEvent);
/**
* Processes a request after all normal request handling has been completed.
*
* @param requestEvent the request event to observe
* @return a list of pairs of account UUID/device ID pairs identifying websockets that need to be refreshed as a
* result of the observed request
*/
List<Pair<UUID, Byte>> handleRequestFinished(RequestEvent requestEvent);
}

View File

@ -34,6 +34,7 @@ import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.util.Util;
@ -85,6 +86,7 @@ public class BackupAuthManager {
* Store credential requests containing blinded backup-ids for future use.
*
* @param account The account using the backup-id
* @param device The device setting the account backup-id
* @param messagesBackupCredentialRequest A request containing the blinded backup-id the client will use to upload
* message backups
* @param mediaBackupCredentialRequest A request containing the blinded backup-id the client will use to upload
@ -92,12 +94,17 @@ public class BackupAuthManager {
* @return A future that completes when the credentialRequest has been stored
* @throws RateLimitExceededException If too many backup-ids have been committed
*/
public CompletableFuture<Void> commitBackupId(final Account account,
public CompletableFuture<Void> commitBackupId(
final Account account,
final Device device,
final BackupAuthCredentialRequest messagesBackupCredentialRequest,
final BackupAuthCredentialRequest mediaBackupCredentialRequest) {
if (configuredBackupLevel(account).isEmpty()) {
throw Status.PERMISSION_DENIED.withDescription("Backups not allowed on account").asRuntimeException();
}
if (!device.isPrimary()) {
throw Status.PERMISSION_DENIED.withDescription("Only primary device can set backup-id").asRuntimeException();
}
final byte[] serializedMessageCredentialRequest = messagesBackupCredentialRequest.serialize();
final byte[] serializedMediaCredentialRequest = mediaBackupCredentialRequest.serialize();

View File

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

View File

@ -1,34 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import jakarta.validation.constraints.NotNull;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
public record CallDnsRecords(
@NotNull
Map<String, List<InetAddress>> aByRegion,
@NotNull
Map<String, List<InetAddress>> aaaaByRegion
) {
public String getSummary() {
int numARecords = aByRegion.values().stream().mapToInt(List::size).sum();
int numAAAARecords = aaaaByRegion.values().stream().mapToInt(List::size).sum();
return String.format(
"(A records, %s regions, %s records), (AAAA records, %s regions, %s records)",
aByRegion.size(),
numARecords,
aaaaByRegion.size(),
numAAAARecords
);
}
public static CallDnsRecords empty() {
return new CallDnsRecords(Map.of(), Map.of());
}
}

View File

@ -1,80 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import io.dropwizard.lifecycle.Managed;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
public class CallDnsRecordsManager implements Supplier<CallDnsRecords>, Managed {
private final S3ObjectMonitor objectMonitor;
private final AtomicReference<CallDnsRecords> callDnsRecords = new AtomicReference<>();
private final Timer refreshTimer;
private static final Logger log = LoggerFactory.getLogger(CallDnsRecordsManager.class);
private static final ObjectMapper objectMapper = JsonMapper.builder()
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
.build();
public CallDnsRecordsManager(final ScheduledExecutorService executorService,
final AwsCredentialsProvider awsCredentialsProvider, final S3ObjectMonitorFactory configuration) {
this.objectMonitor = configuration.build(awsCredentialsProvider, executorService);
this.callDnsRecords.set(CallDnsRecords.empty());
this.refreshTimer = Metrics.timer(MetricsUtil.name(CallDnsRecordsManager.class, "refresh"));
}
private void handleDatabaseChanged(final InputStream inputStream) {
refreshTimer.record(() -> {
try (final InputStream bufferedInputStream = new BufferedInputStream(inputStream)) {
final CallDnsRecords newRecords = parseRecords(bufferedInputStream);
final CallDnsRecords oldRecords = callDnsRecords.getAndSet(newRecords);
log.info("Replaced dns records, old summary=[{}], new summary=[{}]", oldRecords != null ? oldRecords.getSummary() : "null", newRecords);
} catch (final IOException e) {
log.error("Failed to load Call DNS Records");
}
});
}
static CallDnsRecords parseRecords(InputStream inputStream) throws IOException {
return objectMapper.readValue(inputStream, CallDnsRecords.class);
}
@Override
public void start() throws Exception {
objectMonitor.start(this::handleDatabaseChanged);
}
@Override
public void stop() throws Exception {
objectMonitor.stop();
callDnsRecords.getAndSet(null);
}
@Override
public CallDnsRecords get() {
return this.callDnsRecords.get();
}
}

View File

@ -1,193 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigInteger;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.stream.Stream;
public class CallRoutingTable {
private final TreeMap<Integer, Map<Integer, List<String>>> ipv4Map;
private final TreeMap<Integer, Map<BigInteger, List<String>>> ipv6Map;
private final Map<GeoKey, List<String>> geoToDatacenter;
public CallRoutingTable(
Map<CidrBlock.IpV4CidrBlock, List<String>> ipv4SubnetToDatacenter,
Map<CidrBlock.IpV6CidrBlock, List<String>> ipv6SubnetToDatacenter,
Map<GeoKey, List<String>> geoToDatacenter
) {
this.ipv4Map = new TreeMap<>();
for (Map.Entry<CidrBlock.IpV4CidrBlock, List<String>> t : ipv4SubnetToDatacenter.entrySet()) {
if (!this.ipv4Map.containsKey(t.getKey().cidrBlockSize())) {
this.ipv4Map.put(t.getKey().cidrBlockSize(), new HashMap<>());
}
this.ipv4Map
.get(t.getKey().cidrBlockSize())
.put(t.getKey().subnet(), t.getValue());
}
this.ipv6Map = new TreeMap<>();
for (Map.Entry<CidrBlock.IpV6CidrBlock, List<String>> t : ipv6SubnetToDatacenter.entrySet()) {
if (!this.ipv6Map.containsKey(t.getKey().cidrBlockSize())) {
this.ipv6Map.put(t.getKey().cidrBlockSize(), new HashMap<>());
}
this.ipv6Map
.get(t.getKey().cidrBlockSize())
.put(t.getKey().subnet(), t.getValue());
}
this.geoToDatacenter = geoToDatacenter;
}
public static CallRoutingTable empty() {
return new CallRoutingTable(Map.of(), Map.of(), Map.of());
}
public enum Protocol {
v4,
v6
}
public record GeoKey(
@NotBlank String continent,
@NotBlank String country,
@NotNull Optional<String> subdivision,
@NotBlank Protocol protocol
) {}
/**
* Returns ordered list of fastest datacenters based on IP & Geo info. Prioritize the results based on subnet.
* Returns at most three, 2 by subnet and 1 by geo. Takes more from either bucket to hit 3.
*/
public List<String> getDatacentersFor(
InetAddress address,
String continent,
String country,
Optional<String> subdivision
) {
final int NUM_DATACENTERS = 3;
if(this.isEmpty()) {
return Collections.emptyList();
}
List<String> dcsBySubnet = getDatacentersBySubnet(address);
List<String> dcsByGeo = getDatacentersByGeo(continent, country, subdivision).stream()
.limit(NUM_DATACENTERS)
.filter(dc ->
(dcsBySubnet.isEmpty() || !dc.equals(dcsBySubnet.getFirst()))
&& (dcsBySubnet.size() < 2 || !dc.equals(dcsBySubnet.get(1)))
).toList();
return Stream.concat(
dcsBySubnet.stream().limit(dcsByGeo.isEmpty() ? NUM_DATACENTERS : NUM_DATACENTERS - 1),
dcsByGeo.stream())
.limit(NUM_DATACENTERS)
.toList();
}
public boolean isEmpty() {
return this.ipv4Map.isEmpty() && this.ipv6Map.isEmpty() && this.geoToDatacenter.isEmpty();
}
/**
* Returns ordered list of fastest datacenters based on ip info. Prioritizes V4 connections.
*/
public List<String> getDatacentersBySubnet(InetAddress address) throws IllegalArgumentException {
if(address instanceof Inet4Address) {
for(Map.Entry<Integer, Map<Integer, List<String>>> t: this.ipv4Map.descendingMap().entrySet()) {
int maskedIp = CidrBlock.IpV4CidrBlock.maskToSize((Inet4Address) address, t.getKey());
if(t.getValue().containsKey(maskedIp)) {
return t.getValue().get(maskedIp);
}
}
} else if (address instanceof Inet6Address) {
for(Map.Entry<Integer, Map<BigInteger, List<String>>> t: this.ipv6Map.descendingMap().entrySet()) {
BigInteger maskedIp = CidrBlock.IpV6CidrBlock.maskToSize((Inet6Address) address, t.getKey());
if(t.getValue().containsKey(maskedIp)) {
return t.getValue().get(maskedIp);
}
}
} else {
throw new IllegalArgumentException("Expected either an Inet4Address or Inet6Address");
}
return Collections.emptyList();
}
/**
* Returns ordered list of fastest datacenters based on geo info. Attempts to match based on subdivision, falls back
* to country based lookup. Does not attempt to look for nearby subdivisions. Prioritizes V4 connections.
*/
public List<String> getDatacentersByGeo(
String continent,
String country,
Optional<String> subdivision
) {
GeoKey v4Key = new GeoKey(continent, country, subdivision, Protocol.v4);
List<String> v4Options = this.geoToDatacenter.getOrDefault(v4Key, Collections.emptyList());
List<String> v4OptionsBackup = v4Options.isEmpty() && subdivision.isPresent() ?
this.geoToDatacenter.getOrDefault(
new GeoKey(continent, country, Optional.empty(), Protocol.v4),
Collections.emptyList())
: Collections.emptyList();
GeoKey v6Key = new GeoKey(continent, country, subdivision, Protocol.v6);
List<String> v6Options = this.geoToDatacenter.getOrDefault(v6Key, Collections.emptyList());
List<String> v6OptionsBackup = v6Options.isEmpty() && subdivision.isPresent() ?
this.geoToDatacenter.getOrDefault(
new GeoKey(continent, country, Optional.empty(), Protocol.v6),
Collections.emptyList())
: Collections.emptyList();
return Stream.of(
v4Options.stream(),
v6Options.stream(),
v4OptionsBackup.stream(),
v6OptionsBackup.stream()
)
.flatMap(Function.identity())
.distinct()
.toList();
}
public String toSummaryString() {
return String.format(
"[Ipv4Table=%s rows, Ipv6Table=%s rows, GeoTable=%s rows]",
ipv4Map.size(),
ipv6Map.size(),
geoToDatacenter.size()
);
}
@Override
public boolean equals(final Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
CallRoutingTable that = (CallRoutingTable) o;
return Objects.equals(ipv4Map, that.ipv4Map) && Objects.equals(ipv6Map, that.ipv6Map) && Objects.equals(
geoToDatacenter, that.geoToDatacenter);
}
@Override
public int hashCode() {
return Objects.hash(ipv4Map, ipv6Map, geoToDatacenter);
}
}

View File

@ -1,73 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import io.dropwizard.lifecycle.Managed;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
public class CallRoutingTableManager implements Supplier<CallRoutingTable>, Managed {
private final S3ObjectMonitor objectMonitor;
private final AtomicReference<CallRoutingTable> routingTable = new AtomicReference<>();
private final String tableTag;
private final Timer refreshTimer;
private static final Logger log = LoggerFactory.getLogger(CallRoutingTableManager.class);
public CallRoutingTableManager(final ScheduledExecutorService executorService,
final AwsCredentialsProvider awsCredentialsProvider, final S3ObjectMonitorFactory configuration,
final String tableTag) {
this.objectMonitor = configuration.build(awsCredentialsProvider, executorService);
this.tableTag = tableTag;
this.routingTable.set(CallRoutingTable.empty());
this.refreshTimer = Metrics.timer(MetricsUtil.name(CallRoutingTableManager.class, tableTag));
}
private void handleDatabaseChanged(final InputStream inputStream) {
refreshTimer.record(() -> {
try(InputStreamReader reader = new InputStreamReader(inputStream)) {
CallRoutingTable newTable = CallRoutingTableParser.fromJson(reader);
this.routingTable.set(newTable);
log.info("Replaced {} call routing table: {}", tableTag, newTable.toSummaryString());
} catch (final IOException e) {
log.error("Failed to parse and update {} call routing table", tableTag);
}
});
}
@Override
public void start() throws Exception {
objectMonitor.start(this::handleDatabaseChanged);
}
@Override
public void stop() throws Exception {
objectMonitor.stop();
routingTable.getAndSet(null);
}
@Override
public CallRoutingTable get() {
return this.routingTable.get();
}
}

View File

@ -1,185 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
final class CallRoutingTableParser {
private final static int IPV4_DEFAULT_BLOCK_SIZE = 24;
private final static int IPV6_DEFAULT_BLOCK_SIZE = 48;
private static final ObjectMapper objectMapper = JsonMapper.builder()
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
.build();
/** Used for parsing JSON */
private static class RawCallRoutingTable {
public Map<String, List<String>> ipv4GeoToDataCenters = Map.of();
public Map<String, List<String>> ipv6GeoToDataCenters = Map.of();
public Map<String, List<String>> ipv4SubnetsToDatacenters = Map.of();
public Map<String, List<String>> ipv6SubnetsToDatacenters = Map.of();
}
private final static String WHITESPACE_REGEX = "\\s+";
public static CallRoutingTable fromJson(final Reader inputReader) throws IOException {
try (final BufferedReader reader = new BufferedReader(inputReader)) {
RawCallRoutingTable rawTable = objectMapper.readValue(reader, RawCallRoutingTable.class);
Map<CidrBlock.IpV4CidrBlock, List<String>> ipv4SubnetToDatacenter = rawTable.ipv4SubnetsToDatacenters
.entrySet()
.stream()
.collect(Collectors.toUnmodifiableMap(
e -> (CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock(e.getKey(), IPV4_DEFAULT_BLOCK_SIZE),
Map.Entry::getValue
));
Map<CidrBlock.IpV6CidrBlock, List<String>> ipv6SubnetToDatacenter = rawTable.ipv6SubnetsToDatacenters
.entrySet()
.stream()
.collect(Collectors.toUnmodifiableMap(
e -> (CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock(e.getKey(), IPV6_DEFAULT_BLOCK_SIZE),
Map.Entry::getValue
));
Map<CallRoutingTable.GeoKey, List<String>> geoToDatacenter = Stream.concat(
rawTable.ipv4GeoToDataCenters
.entrySet()
.stream()
.map(e -> Map.entry(parseRawGeoKey(e.getKey(), CallRoutingTable.Protocol.v4), e.getValue())),
rawTable.ipv6GeoToDataCenters
.entrySet()
.stream()
.map(e -> Map.entry(parseRawGeoKey(e.getKey(), CallRoutingTable.Protocol.v6), e.getValue()))
).collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue));
return new CallRoutingTable(
ipv4SubnetToDatacenter,
ipv6SubnetToDatacenter,
geoToDatacenter
);
}
}
private static CallRoutingTable.GeoKey parseRawGeoKey(String rawKey, CallRoutingTable.Protocol protocol) {
String[] splits = rawKey.split("-");
if (splits.length < 2 || splits.length > 3) {
throw new IllegalArgumentException("Invalid raw key");
}
Optional<String> subdivision = splits.length < 3 ? Optional.empty() : Optional.of(splits[2]);
return new CallRoutingTable.GeoKey(splits[0], splits[1], subdivision, protocol);
}
/**
* Parses a call routing table in TSV format. Example below - see tests for more examples:
192.0.2.0/24 northamerica-northeast1
198.51.100.0/24 us-south1
203.0.113.0/24 asia-southeast1
2001:db8:b0a9::/48 us-east4
2001:db8:b0f5::/48 us-central1 northamerica-northeast1 us-east4
2001:db8:9406::/48 us-east1 us-central1
SA-SR-v4 us-east1 us-east4
SA-SR-v6 us-east1 us-south1
SA-UY-v4 southamerica-west1 southamerica-east1 europe-west3
SA-UY-v6 southamerica-west1 europe-west4
SA-VE-v4 us-east1 us-east4 us-south1
SA-VE-v6 us-east1 northamerica-northeast1 us-east4
ZZ-ZZ-v4 asia-south1 europe-southwest1 australia-southeast1
*/
public static CallRoutingTable fromTsv(final Reader inputReader) throws IOException {
try (final BufferedReader reader = new BufferedReader(inputReader)) {
// use maps to silently dedupe CidrBlocks
Map<CidrBlock.IpV4CidrBlock, List<String>> ipv4Map = new HashMap<>();
Map<CidrBlock.IpV6CidrBlock, List<String>> ipv6Map = new HashMap<>();
Map<CallRoutingTable.GeoKey, List<String>> ipGeoTable = new HashMap<>();
String line;
while((line = reader.readLine()) != null) {
if(line.isBlank()) {
continue;
}
List<String> splits = Arrays.stream(line.split(WHITESPACE_REGEX)).filter(s -> !s.isBlank()).toList();
if (splits.size() < 2) {
throw new IllegalStateException("Invalid row, expected some key and list of values");
}
List<String> datacenters = splits.subList(1, splits.size());
switch (guessLineType(splits)) {
case v4 -> {
CidrBlock cidrBlock = CidrBlock.parseCidrBlock(splits.getFirst());
if(!(cidrBlock instanceof CidrBlock.IpV4CidrBlock)) {
throw new IllegalArgumentException("Expected an ipv4 cidr block");
}
ipv4Map.put((CidrBlock.IpV4CidrBlock) cidrBlock, datacenters);
}
case v6 -> {
CidrBlock cidrBlock = CidrBlock.parseCidrBlock(splits.getFirst());
if(!(cidrBlock instanceof CidrBlock.IpV6CidrBlock)) {
throw new IllegalArgumentException("Expected an ipv6 cidr block");
}
ipv6Map.put((CidrBlock.IpV6CidrBlock) cidrBlock, datacenters);
}
case Geo -> {
String[] geo = splits.getFirst().split("-");
if(geo.length < 3) {
throw new IllegalStateException("Geo row key invalid, expected atleast continent, country, and protocol");
}
String continent = geo[0];
String country = geo[1];
Optional<String> subdivision = geo.length > 3 ? Optional.of(geo[2]) : Optional.empty();
CallRoutingTable.Protocol protocol = CallRoutingTable.Protocol.valueOf(geo[geo.length - 1].toLowerCase());
CallRoutingTable.GeoKey tableKey = new CallRoutingTable.GeoKey(
continent,
country,
subdivision,
protocol
);
ipGeoTable.put(tableKey, datacenters);
}
}
}
return new CallRoutingTable(
ipv4Map,
ipv6Map,
ipGeoTable
);
}
}
private static LineType guessLineType(List<String> splits) {
String first = splits.getFirst();
if (first.contains("-")) {
return LineType.Geo;
} else if(first.contains(":")) {
return LineType.v6;
} else if (first.contains(".")) {
return LineType.v4;
}
throw new IllegalArgumentException(String.format("Invalid line, could not determine type from '%s'", first));
}
private enum LineType {
v4, v6, Geo
}
}

View File

@ -1,137 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import java.math.BigInteger;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
/**
* Can be used to check if an IP is in the CIDR block
*/
public interface CidrBlock {
boolean ipInBlock(InetAddress address);
static CidrBlock parseCidrBlock(String cidrBlock, int defaultBlockSize) {
String[] splits = cidrBlock.split("/");
if(splits.length > 2) {
throw new IllegalArgumentException("Invalid cidr block format, expected {address}/{blocksize}");
}
try {
int blockSize = splits.length == 2 ? Integer.parseInt(splits[1]) : defaultBlockSize;
return parseCidrBlockInner(splits[0], blockSize);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(String.format("Invalid block size specified: '%s'", splits[1]));
}
}
static CidrBlock parseCidrBlock(String cidrBlock) {
String[] splits = cidrBlock.split("/");
if (splits.length != 2) {
throw new IllegalArgumentException("Invalid cidr block format, expected {address}/{blocksize}");
}
try {
int blockSize = Integer.parseInt(splits[1]);
return parseCidrBlockInner(splits[0], blockSize);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(String.format("Invalid block size specified: '%s'", splits[1]));
}
}
private static CidrBlock parseCidrBlockInner(String rawAddress, int blockSize) {
try {
InetAddress address = InetAddress.getByName(rawAddress);
if(address instanceof Inet4Address) {
return IpV4CidrBlock.of((Inet4Address) address, blockSize);
} else if (address instanceof Inet6Address) {
return IpV6CidrBlock.of((Inet6Address) address, blockSize);
} else {
throw new IllegalArgumentException("Must be an ipv4 or ipv6 string");
}
} catch (UnknownHostException e) {
throw new IllegalArgumentException(e);
}
}
record IpV4CidrBlock(int subnet, int subnetMask, int cidrBlockSize) implements CidrBlock {
public static IpV4CidrBlock of(Inet4Address subnet, int cidrBlockSize) {
if(cidrBlockSize > 32 || cidrBlockSize < 0) {
throw new IllegalArgumentException("Invalid cidrBlockSize");
}
int subnetMask = mask(cidrBlockSize);
int maskedIp = ipToInt(subnet) & subnetMask;
return new IpV4CidrBlock(maskedIp, subnetMask, cidrBlockSize);
}
public boolean ipInBlock(InetAddress address) {
if(!(address instanceof Inet4Address)) {
return false;
}
int ip = ipToInt((Inet4Address) address);
return (ip & subnetMask) == subnet;
}
private static int ipToInt(Inet4Address address) {
byte[] octets = address.getAddress();
return (octets[0] & 0xff) << 24 |
(octets[1] & 0xff) << 16 |
(octets[2] & 0xff) << 8 |
octets[3] & 0xff;
}
private static int mask(int cidrBlockSize) {
return (int) (-1L << (32 - cidrBlockSize));
}
public static int maskToSize(Inet4Address address, int cidrBlockSize) {
return ipToInt(address) & mask(cidrBlockSize);
}
}
record IpV6CidrBlock(BigInteger subnet, BigInteger subnetMask, int cidrBlockSize) implements CidrBlock {
private static final BigInteger MINUS_ONE = BigInteger.valueOf(-1);
public static IpV6CidrBlock of(Inet6Address subnet, int cidrBlockSize) {
if(cidrBlockSize > 128 || cidrBlockSize < 0) {
throw new IllegalArgumentException("Invalid cidrBlockSize");
}
BigInteger subnetMask = mask(cidrBlockSize);
BigInteger maskedIp = ipToInt(subnet).and(subnetMask);
return new IpV6CidrBlock(maskedIp, subnetMask, cidrBlockSize);
}
public boolean ipInBlock(InetAddress address) {
if(!(address instanceof Inet6Address)) {
return false;
}
BigInteger ip = ipToInt((Inet6Address) address);
return ip.and(subnetMask).equals(subnet);
}
private static BigInteger ipToInt(Inet6Address ipAddress) {
byte[] octets = ipAddress.getAddress();
assert octets.length == 16;
return new BigInteger(octets);
}
private static BigInteger mask(int cidrBlockSize) {
return MINUS_ONE.shiftLeft(128 - cidrBlockSize);
}
public static BigInteger maskToSize(Inet6Address address, int cidrBlockSize) {
return ipToInt(address).and(mask(cidrBlockSize));
}
}
}

View File

@ -1,16 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import java.util.List;
import java.util.Optional;
public record TurnServerOptions(
String hostname,
Optional<List<String>> urlsWithIps,
Optional<List<String>> urlsWithHostname
) {
}

View File

@ -60,6 +60,7 @@ public class DynamoDbTables {
private final Table ecSignedPreKeys;
private final Table kemKeys;
private final Table kemLastResortKeys;
private final Table pagedKemKeys;
private final TableWithExpiration messages;
private final TableWithExpiration onetimeDonations;
private final Table phoneNumberIdentifiers;
@ -88,6 +89,7 @@ public class DynamoDbTables {
@JsonProperty("ecSignedPreKeys") final Table ecSignedPreKeys,
@JsonProperty("pqKeys") final Table kemKeys,
@JsonProperty("pqLastResortKeys") final Table kemLastResortKeys,
@JsonProperty("pagedPqKeys") final Table pagedKemKeys,
@JsonProperty("messages") final TableWithExpiration messages,
@JsonProperty("onetimeDonations") final TableWithExpiration onetimeDonations,
@JsonProperty("phoneNumberIdentifiers") final Table phoneNumberIdentifiers,
@ -114,6 +116,7 @@ public class DynamoDbTables {
this.ecKeys = ecKeys;
this.ecSignedPreKeys = ecSignedPreKeys;
this.kemKeys = kemKeys;
this.pagedKemKeys = pagedKemKeys;
this.kemLastResortKeys = kemLastResortKeys;
this.messages = messages;
this.onetimeDonations = onetimeDonations;
@ -202,6 +205,12 @@ public class DynamoDbTables {
return kemKeys;
}
@NotNull
@Valid
public Table getPagedKemKeys() {
return pagedKemKeys;
}
@NotNull
@Valid
public Table getKemLastResortKeys() {

View File

@ -0,0 +1,15 @@
/*
* Copyright 2013 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record PagedSingleUseKEMPreKeyStoreConfiguration(
@NotBlank String bucket,
@NotBlank String region) {
}

View File

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

View File

@ -66,8 +66,6 @@ import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.websocket.auth.Mutable;
import org.whispersystems.websocket.auth.ReadOnly;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/accounts")
@ -97,11 +95,14 @@ public class AccountController {
@Path("/gcm/")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public void setGcmRegistrationId(@Mutable @Auth AuthenticatedDevice auth,
public void setGcmRegistrationId(@Auth AuthenticatedDevice auth,
@NotNull @Valid GcmRegistrationId registrationId) {
final Account account = auth.getAccount();
final Device device = auth.getAuthenticatedDevice();
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
final Device device = account.getDevice(auth.deviceId())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
if (Objects.equals(device.getGcmId(), registrationId.gcmRegistrationId())) {
return;
@ -116,9 +117,12 @@ public class AccountController {
@DELETE
@Path("/gcm/")
public void deleteGcmRegistrationId(@Mutable @Auth AuthenticatedDevice auth) {
Account account = auth.getAccount();
Device device = auth.getAuthenticatedDevice();
public void deleteGcmRegistrationId(@Auth AuthenticatedDevice auth) {
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
final Device device = account.getDevice(auth.deviceId())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
accounts.updateDevice(account, device.getId(), d -> {
d.setGcmId(null);
@ -131,11 +135,14 @@ public class AccountController {
@Path("/apn/")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public void setApnRegistrationId(@Mutable @Auth AuthenticatedDevice auth,
public void setApnRegistrationId(@Auth AuthenticatedDevice auth,
@NotNull @Valid ApnRegistrationId registrationId) {
final Account account = auth.getAccount();
final Device device = auth.getAuthenticatedDevice();
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
final Device device = account.getDevice(auth.deviceId())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
// Unlike FCM tokens, we need current "last updated" timestamps for APNs tokens and so update device records
// unconditionally
@ -148,9 +155,12 @@ public class AccountController {
@DELETE
@Path("/apn/")
public void deleteApnRegistrationId(@Mutable @Auth AuthenticatedDevice auth) {
Account account = auth.getAccount();
Device device = auth.getAuthenticatedDevice();
public void deleteApnRegistrationId(@Auth AuthenticatedDevice auth) {
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
final Device device = account.getDevice(auth.deviceId())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
accounts.updateDevice(account, device.getId(), d -> {
d.setApnId(null);
@ -166,17 +176,23 @@ public class AccountController {
@PUT
@Produces(MediaType.APPLICATION_JSON)
@Path("/registration_lock")
public void setRegistrationLock(@Mutable @Auth AuthenticatedDevice auth, @NotNull @Valid RegistrationLock accountLock) {
SaltedTokenHash credentials = SaltedTokenHash.generateFor(accountLock.getRegistrationLock());
public void setRegistrationLock(@Auth AuthenticatedDevice auth, @NotNull @Valid RegistrationLock accountLock) {
final SaltedTokenHash credentials = SaltedTokenHash.generateFor(accountLock.getRegistrationLock());
accounts.update(auth.getAccount(),
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
accounts.update(account,
a -> a.setRegistrationLock(credentials.hash(), credentials.salt()));
}
@DELETE
@Path("/registration_lock")
public void removeRegistrationLock(@Mutable @Auth AuthenticatedDevice auth) {
accounts.update(auth.getAccount(), a -> a.setRegistrationLock(null, null));
public void removeRegistrationLock(@Auth AuthenticatedDevice auth) {
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
accounts.update(account, a -> a.setRegistrationLock(null, null));
}
@PUT
@ -190,7 +206,7 @@ public class AccountController {
@ApiResponse(responseCode = "204", description = "Device name changed successfully")
@ApiResponse(responseCode = "404", description = "No device found with the given ID")
@ApiResponse(responseCode = "403", description = "Not authorized to change the name of the device with the given ID")
public void setName(@Mutable @Auth final AuthenticatedDevice auth,
public void setName(@Auth final AuthenticatedDevice auth,
@NotNull @Valid final DeviceName deviceName,
@Nullable
@ -199,15 +215,16 @@ public class AccountController {
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
final Byte deviceId) {
final Account account = auth.getAccount();
final byte targetDeviceId = deviceId == null ? auth.getAuthenticatedDevice().getId() : deviceId;
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
final byte targetDeviceId = deviceId == null ? auth.deviceId() : deviceId;
if (account.getDevice(targetDeviceId).isEmpty()) {
throw new NotFoundException();
}
final boolean mayChangeName = auth.getAuthenticatedDevice().isPrimary() ||
auth.getAuthenticatedDevice().getId() == targetDeviceId;
final boolean mayChangeName = auth.deviceId() == Device.PRIMARY_ID || auth.deviceId() == targetDeviceId;
if (!mayChangeName) {
throw new ForbiddenException();
@ -221,14 +238,14 @@ public class AccountController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public void setAccountAttributes(
@Mutable @Auth AuthenticatedDevice auth,
@Auth AuthenticatedDevice auth,
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
@NotNull @Valid AccountAttributes attributes) {
final Account account = auth.getAccount();
final byte deviceId = auth.getAuthenticatedDevice().getId();
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
final Account updatedAccount = accounts.update(account, a -> {
a.getDevice(deviceId).ifPresent(d -> {
a.getDevice(auth.deviceId()).ifPresent(d -> {
d.setFetchesMessages(attributes.getFetchesMessages());
d.setName(attributes.getName());
d.setLastSeen(Util.todayInMillis());
@ -252,8 +269,11 @@ public class AccountController {
@GET
@Path("/whoami")
@Produces(MediaType.APPLICATION_JSON)
public AccountIdentityResponse whoAmI(@ReadOnly @Auth AuthenticatedDevice auth) {
return AccountIdentityResponseBuilder.fromAccount(auth.getAccount());
public AccountIdentityResponse whoAmI(@Auth final AuthenticatedDevice auth) {
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
return AccountIdentityResponseBuilder.fromAccount(account);
}
@DELETE
@ -267,8 +287,11 @@ public class AccountController {
)
@ApiResponse(responseCode = "204", description = "Username successfully deleted.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
public CompletableFuture<Response> deleteUsernameHash(@Mutable @Auth final AuthenticatedDevice auth) {
return accounts.clearUsernameHash(auth.getAccount())
public CompletableFuture<Response> deleteUsernameHash(@Auth final AuthenticatedDevice auth) {
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
return accounts.clearUsernameHash(account)
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
}
@ -289,10 +312,13 @@ public class AccountController {
@ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
public CompletableFuture<ReserveUsernameHashResponse> reserveUsernameHash(
@Mutable @Auth final AuthenticatedDevice auth,
@Auth final AuthenticatedDevice auth,
@NotNull @Valid final ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException {
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
rateLimiters.getUsernameReserveLimiter().validate(auth.accountIdentifier());
for (final byte[] hash : usernameRequest.usernameHashes()) {
if (hash.length != USERNAME_HASH_LENGTH) {
@ -300,7 +326,7 @@ public class AccountController {
}
}
return accounts.reserveUsernameHash(auth.getAccount(), usernameRequest.usernameHashes())
return accounts.reserveUsernameHash(account, usernameRequest.usernameHashes())
.thenApply(reservation -> new ReserveUsernameHashResponse(reservation.reservedUsernameHash()))
.exceptionally(throwable -> {
if (ExceptionUtils.unwrap(throwable) instanceof UsernameHashNotAvailableException) {
@ -329,18 +355,21 @@ public class AccountController {
@ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
public CompletableFuture<UsernameHashResponse> confirmUsernameHash(
@Mutable @Auth final AuthenticatedDevice auth,
@Auth final AuthenticatedDevice auth,
@NotNull @Valid final ConfirmUsernameHashRequest confirmRequest) {
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
try {
usernameHashZkProofVerifier.verifyProof(confirmRequest.zkProof(), confirmRequest.usernameHash());
} catch (final BaseUsernameException e) {
throw new WebApplicationException(Response.status(422).build());
}
return rateLimiters.getUsernameSetLimiter().validateAsync(auth.getAccount().getUuid())
return rateLimiters.getUsernameSetLimiter().validateAsync(account.getUuid())
.thenCompose(ignored -> accounts.confirmReservedUsernameHash(
auth.getAccount(),
account,
confirmRequest.usernameHash(),
confirmRequest.encryptedUsername()))
.thenApply(updatedAccount -> new UsernameHashResponse(updatedAccount.getUsernameHash()
@ -374,7 +403,7 @@ public class AccountController {
@ApiResponse(responseCode = "400", description = "Request must not be authenticated.")
@ApiResponse(responseCode = "404", description = "Account not found for the given username.")
public CompletableFuture<AccountIdentifierResponse> lookupUsernameHash(
@ReadOnly @Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
@Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
@PathParam("usernameHash") final String usernameHash) {
requireNotAuthenticated(maybeAuthenticatedAccount);
@ -413,12 +442,14 @@ public class AccountController {
@ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
public UsernameLinkHandle updateUsernameLink(
@Mutable @Auth final AuthenticatedDevice auth,
@Auth final AuthenticatedDevice auth,
@NotNull @Valid final EncryptedUsername encryptedUsername) throws RateLimitExceededException {
// check ratelimiter for username link operations
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccount().getUuid());
final Account account = auth.getAccount();
// check ratelimiter for username link operations
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.accountIdentifier());
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
// check if username hash is set for the account
if (account.getUsernameHash().isEmpty()) {
@ -431,7 +462,7 @@ public class AccountController {
} else {
usernameLinkHandle = UUID.randomUUID();
}
updateUsernameLink(auth.getAccount(), usernameLinkHandle, encryptedUsername.usernameLinkEncryptedValue());
updateUsernameLink(account, usernameLinkHandle, encryptedUsername.usernameLinkEncryptedValue());
return new UsernameLinkHandle(usernameLinkHandle);
}
@ -447,10 +478,14 @@ public class AccountController {
@ApiResponse(responseCode = "204", description = "Username Link successfully deleted.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
public void deleteUsernameLink(@Mutable @Auth final AuthenticatedDevice auth) throws RateLimitExceededException {
public void deleteUsernameLink(@Auth final AuthenticatedDevice auth) throws RateLimitExceededException {
// check ratelimiter for username link operations
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccount().getUuid());
clearUsernameLink(auth.getAccount());
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.accountIdentifier());
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
clearUsernameLink(account);
}
@GET
@ -470,7 +505,7 @@ public class AccountController {
@ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
public CompletableFuture<EncryptedUsername> lookupUsernameLink(
@ReadOnly @Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
@Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
@PathParam("uuid") final UUID usernameLinkHandle) {
requireNotAuthenticated(maybeAuthenticatedAccount);
@ -496,7 +531,7 @@ public class AccountController {
@Path("/account/{identifier}")
@RateLimitedByIp(RateLimiters.For.CHECK_ACCOUNT_EXISTENCE)
public Response accountExists(
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Parameter(description = "An ACI or PNI account identifier to check")
@PathParam("identifier") final ServiceIdentifier accountIdentifier) {
@ -511,8 +546,11 @@ public class AccountController {
@DELETE
@Path("/me")
public CompletableFuture<Response> deleteAccount(@Mutable @Auth AuthenticatedDevice auth) {
return accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST).thenApply(Util.ASYNC_EMPTY_RESPONSE);
public CompletableFuture<Response> deleteAccount(@Auth AuthenticatedDevice auth) {
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
return accounts.delete(account, AccountsManager.DeletionReason.USER_REQUEST).thenApply(Util.ASYNC_EMPTY_RESPONSE);
}
private void clearUsernameLink(final Account account) {

View File

@ -55,8 +55,7 @@ import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
import org.whispersystems.websocket.auth.Mutable;
import org.whispersystems.websocket.auth.ReadOnly;
import org.whispersystems.textsecuregcm.storage.Device;
@Path("/v2/accounts")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Account")
@ -101,12 +100,12 @@ public class AccountControllerV2 {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public AccountIdentityResponse changeNumber(@Mutable @Auth final AuthenticatedDevice authenticatedDevice,
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedDevice authenticatedDevice,
@NotNull @Valid final ChangeNumberRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString,
@Context final ContainerRequestContext requestContext) throws RateLimitExceededException, InterruptedException {
if (!authenticatedDevice.getAuthenticatedDevice().isPrimary()) {
if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {
throw new ForbiddenException();
}
@ -116,8 +115,11 @@ public class AccountControllerV2 {
final String number = request.number();
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
// Only verify and check reglock if there's a data change to be made...
if (!authenticatedDevice.getAccount().getNumber().equals(number)) {
if (!account.getNumber().equals(number)) {
rateLimiters.getRegistrationLimiter().validate(number);
@ -139,7 +141,7 @@ public class AccountControllerV2 {
// ...but always attempt to make the change in case a client retries and needs to re-send messages
try {
final Account updatedAccount = changeNumberManager.changeNumber(
authenticatedDevice.getAccount(),
account,
request.number(),
request.pniIdentityKey(),
request.devicePniSignedPrekeys(),
@ -185,11 +187,11 @@ public class AccountControllerV2 {
content = @Content(schema = @Schema(implementation = StaleDevicesResponse.class)))
@ApiResponse(responseCode = "413", description = "One or more device messages was too large")
public AccountIdentityResponse distributePhoneNumberIdentityKeys(
@Mutable @Auth final AuthenticatedDevice authenticatedDevice,
@Auth final AuthenticatedDevice authenticatedDevice,
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString,
@NotNull @Valid final PhoneNumberIdentityKeyDistributionRequest request) {
if (!authenticatedDevice.getAuthenticatedDevice().isPrimary()) {
if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {
throw new ForbiddenException();
}
@ -197,9 +199,12 @@ public class AccountControllerV2 {
throw new WebApplicationException("Invalid signature", 422);
}
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
try {
final Account updatedAccount = changeNumberManager.updatePniKeys(
authenticatedDevice.getAccount(),
account,
request.pniIdentityKey(),
request.devicePniSignedPrekeys(),
request.devicePniPqLastResortPrekeys(),
@ -235,10 +240,13 @@ public class AccountControllerV2 {
@Operation(summary = "Sets whether the account should be discoverable by phone number in the directory.")
@ApiResponse(responseCode = "204", description = "The setting was successfully updated.")
public void setPhoneNumberDiscoverability(
@Mutable @Auth AuthenticatedDevice auth,
@NotNull @Valid PhoneNumberDiscoverabilityRequest phoneNumberDiscoverability
) {
accountsManager.update(auth.getAccount(), a -> a.setDiscoverableByPhoneNumber(
@Auth AuthenticatedDevice auth,
@NotNull @Valid PhoneNumberDiscoverabilityRequest phoneNumberDiscoverability) {
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
accountsManager.update(account, a -> a.setDiscoverableByPhoneNumber(
phoneNumberDiscoverability.discoverableByPhoneNumber()));
}
@ -249,9 +257,10 @@ public class AccountControllerV2 {
@ApiResponse(responseCode = "200",
description = "Response with data report. A plain text representation is a field in the response.",
useReturnTypeSchema = true)
public AccountDataReportResponse getAccountDataReport(@ReadOnly @Auth final AuthenticatedDevice auth) {
public AccountDataReportResponse getAccountDataReport(@Auth final AuthenticatedDevice auth) {
final Account account = auth.getAccount();
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return new AccountDataReportResponse(UUID.randomUUID(), Instant.now(),
new AccountDataReportResponse.AccountAndDevicesDataReport(

View File

@ -37,6 +37,7 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
@ -71,14 +72,15 @@ import org.whispersystems.textsecuregcm.backup.MediaEncryptionParameters;
import org.whispersystems.textsecuregcm.entities.RemoteAttachment;
import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.util.BackupAuthCredentialAdapter;
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
import org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.websocket.auth.Mutable;
import org.whispersystems.websocket.auth.ReadOnly;
import reactor.core.publisher.Mono;
@Path("/v1/archives")
@ -88,14 +90,18 @@ public class ArchiveController {
public final static String X_SIGNAL_ZK_AUTH = "X-Signal-ZK-Auth";
public final static String X_SIGNAL_ZK_AUTH_SIGNATURE = "X-Signal-ZK-Auth-Signature";
private final AccountsManager accountsManager;
private final BackupAuthManager backupAuthManager;
private final BackupManager backupManager;
private final BackupMetrics backupMetrics;
public ArchiveController(
final AccountsManager accountsManager,
final BackupAuthManager backupAuthManager,
final BackupManager backupManager,
final BackupMetrics backupMetrics) {
this.accountsManager = accountsManager;
this.backupAuthManager = backupAuthManager;
this.backupManager = backupManager;
this.backupMetrics = backupMetrics;
@ -135,15 +141,25 @@ public class ArchiveController {
""")
@ApiResponse(responseCode = "204", description = "The backup-id was set")
@ApiResponse(responseCode = "400", description = "The provided backup auth credential request was invalid")
@ApiResponse(responseCode = "403", description = "The device did not have permission to set the backup-id. Only the primary device can set the backup-id for an account")
@ApiResponse(responseCode = "429", description = "Rate limited. Too many attempts to change the backup-id have been made")
public CompletionStage<Response> setBackupId(
@Mutable @Auth final AuthenticatedDevice account,
@Auth final AuthenticatedDevice authenticatedDevice,
@Valid @NotNull final SetBackupIdRequest setBackupIdRequest) throws RateLimitExceededException {
return this.backupAuthManager
.commitBackupId(account.getAccount(), setBackupIdRequest.messagesBackupAuthCredentialRequest,
setBackupIdRequest.mediaBackupAuthCredentialRequest)
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final Device device = account.getDevice(authenticatedDevice.deviceId())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return backupAuthManager
.commitBackupId(account, device, setBackupIdRequest.messagesBackupAuthCredentialRequest,
setBackupIdRequest.mediaBackupAuthCredentialRequest)
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
});
}
public record RedeemBackupReceiptRequest(
@ -187,12 +203,17 @@ public class ArchiveController {
@ApiResponse(responseCode = "409", description = "The target account does not have a backup-id commitment")
@ApiResponse(responseCode = "429", description = "Rate limited.")
public CompletionStage<Response> redeemReceipt(
@Mutable @Auth final AuthenticatedDevice account,
@Auth final AuthenticatedDevice authenticatedDevice,
@Valid @NotNull final RedeemBackupReceiptRequest redeemBackupReceiptRequest) {
return this.backupAuthManager.redeemReceipt(
account.getAccount(),
redeemBackupReceiptRequest.receiptCredentialPresentation())
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return backupAuthManager.redeemReceipt(account, redeemBackupReceiptRequest.receiptCredentialPresentation())
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
});
}
public record BackupAuthCredentialsResponse(
@ -251,7 +272,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "404", description = "Could not find an existing blinded backup id")
@ApiResponse(responseCode = "429", description = "Rate limited.")
public CompletionStage<BackupAuthCredentialsResponse> getBackupZKCredentials(
@Mutable @Auth AuthenticatedDevice auth,
@Auth AuthenticatedDevice authenticatedDevice,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@NotNull @QueryParam("redemptionStartSeconds") Long startSeconds,
@NotNull @QueryParam("redemptionEndSeconds") Long endSeconds) {
@ -259,27 +280,33 @@ public class ArchiveController {
final Map<BackupCredentialType, List<BackupAuthCredentialsResponse.BackupAuthCredential>> credentialsByType =
new ConcurrentHashMap<>();
return CompletableFuture.allOf(Arrays.stream(BackupCredentialType.values())
.map(credentialType -> this.backupAuthManager.getBackupAuthCredentials(
auth.getAccount(),
credentialType,
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
.thenAccept(credentials -> {
backupMetrics.updateGetCredentialCounter(
UserAgentTagUtil.getPlatformTag(userAgent),
credentialType,
credentials.size());
credentialsByType.put(credentialType, credentials.stream()
.map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(
credential.credential().serialize(),
credential.redemptionTime().getEpochSecond()))
.toList());
}))
.toArray(CompletableFuture[]::new))
.thenApply(ignored -> new BackupAuthCredentialsResponse(credentialsByType.entrySet().stream()
.collect(Collectors.toMap(
e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),
Map.Entry::getValue))));
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return CompletableFuture.allOf(Arrays.stream(BackupCredentialType.values())
.map(credentialType -> this.backupAuthManager.getBackupAuthCredentials(
account,
credentialType,
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
.thenAccept(credentials -> {
backupMetrics.updateGetCredentialCounter(
UserAgentTagUtil.getPlatformTag(userAgent),
credentialType,
credentials.size());
credentialsByType.put(credentialType, credentials.stream()
.map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(
credential.credential().serialize(),
credential.redemptionTime().getEpochSecond()))
.toList());
}))
.toArray(CompletableFuture[]::new))
.thenApply(ignored -> new BackupAuthCredentialsResponse(credentialsByType.entrySet().stream()
.collect(Collectors.toMap(
e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),
Map.Entry::getValue))));
});
}
@ -342,7 +369,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<ReadAuthResponse> readAuth(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@ -394,7 +421,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<BackupInfoResponse> backupInfo(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@ -440,7 +467,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "204", description = "The public key was set")
@ApiResponse(responseCode = "429", description = "Rate limited.")
public CompletionStage<Response> setPublicKey(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@NotNull
@ -480,7 +507,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<UploadDescriptorResponse> backup(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@ -517,7 +544,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<UploadDescriptorResponse> uploadTemporaryAttachment(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@ -605,7 +632,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<CopyMediaResponse> copyMedia(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@ -704,7 +731,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<Response> copyMedia(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@ -743,7 +770,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<Response> refresh(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@ -810,7 +837,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<ListResponse> listMedia(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@ -866,7 +893,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<Response> deleteMedia(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
@ -903,7 +930,7 @@ public class ArchiveController {
@ApiResponse(responseCode = "429", description = "Rate limited.")
@ApiResponseZkAuth
public CompletionStage<Response> deleteBackup(
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
@Auth final Optional<AuthenticatedDevice> account,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))

View File

@ -26,7 +26,6 @@ import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.websocket.auth.ReadOnly;
/**
@ -78,11 +77,11 @@ public class AttachmentControllerV4 {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public AttachmentDescriptorV3 getAttachmentUploadForm(@ReadOnly @Auth AuthenticatedDevice auth)
public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth AuthenticatedDevice auth)
throws RateLimitExceededException {
rateLimiter.validate(auth.getAccount().getUuid());
rateLimiter.validate(auth.accountIdentifier());
final String key = generateAttachmentKey();
final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.getAccount().getUuid(), CDN3_EXPERIMENT_NAME);
final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.accountIdentifier(), CDN3_EXPERIMENT_NAME);
int cdn = useCdn3 ? 3 : 2;
final AttachmentGenerator.Descriptor descriptor = this.attachmentGenerators.get(cdn).generateAttachment(key);
return new AttachmentDescriptorV3(cdn, key, descriptor.headers(), descriptor.signedUploadLocation());

View File

@ -20,7 +20,6 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.entities.CreateCallLinkCredential;
import org.whispersystems.textsecuregcm.entities.GetCreateCallLinkCredentialsRequest;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v1/call-link")
@io.swagger.v3.oas.annotations.tags.Tag(name = "CallLink")
@ -52,11 +51,11 @@ public class CallLinkController {
@ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
public CreateCallLinkCredential getCreateAuth(
final @ReadOnly @Auth AuthenticatedDevice auth,
final @Auth AuthenticatedDevice auth,
final @NotNull @Valid GetCreateCallLinkCredentialsRequest request
) throws RateLimitExceededException {
rateLimiters.getCreateCallLinkLimiter().validate(auth.getAccount().getUuid());
rateLimiters.getCreateCallLinkLimiter().validate(auth.accountIdentifier());
final Instant truncatedDayTimestamp = Instant.now().truncatedTo(ChronoUnit.DAYS);
@ -68,7 +67,7 @@ public class CallLinkController {
}
return new CreateCallLinkCredential(
createCallLinkCredentialRequest.issueCredential(new ServiceId.Aci(auth.getAccount().getUuid()), truncatedDayTimestamp, genericServerSecretParams).serialize(),
createCallLinkCredentialRequest.issueCredential(new ServiceId.Aci(auth.accountIdentifier()), truncatedDayTimestamp, genericServerSecretParams).serialize(),
truncatedDayTimestamp.getEpochSecond()
);
}

View File

@ -18,11 +18,9 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.websocket.auth.ReadOnly;
@io.swagger.v3.oas.annotations.tags.Tag(name = "Calling")
@Path("/v2/calling")
@ -56,11 +54,10 @@ public class CallRoutingControllerV2 {
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
@ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Rate limited.")
public GetCallingRelaysResponse getCallingRelays(final @ReadOnly @Auth AuthenticatedDevice auth)
public GetCallingRelaysResponse getCallingRelays(final @Auth AuthenticatedDevice auth)
throws RateLimitExceededException, IOException {
final UUID aci = auth.getAccount().getUuid();
rateLimiters.getCallEndpointLimiter().validate(aci);
rateLimiters.getCallEndpointLimiter().validate(auth.accountIdentifier());
try {
return new GetCallingRelaysResponse(List.of(cloudflareTurnCredentialsManager.retrieveFromCloudflare()));

View File

@ -8,18 +8,18 @@ package org.whispersystems.textsecuregcm.controllers;
import static com.codahale.metrics.MetricRegistry.name;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.net.HttpHeaders;
import io.dropwizard.auth.Auth;
import io.micrometer.core.instrument.Metrics;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.security.InvalidKeyException;
import java.time.Clock;
import java.time.Duration;
@ -38,13 +38,16 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.entities.DeliveryCertificate;
import org.whispersystems.textsecuregcm.entities.GroupCredentials;
import org.whispersystems.websocket.auth.ReadOnly;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/certificate")
@Tag(name = "Certificate")
public class CertificateController {
private final AccountsManager accountsManager;
private final CertificateGenerator certificateGenerator;
private final ServerZkAuthOperations serverZkAuthOperations;
private final GenericServerSecretParams genericServerSecretParams;
@ -56,10 +59,13 @@ public class CertificateController {
private static final String INCLUDE_E164_TAG_NAME = "includeE164";
public CertificateController(
final AccountsManager accountsManager,
@Nonnull CertificateGenerator certificateGenerator,
@Nonnull ServerZkAuthOperations serverZkAuthOperations,
@Nonnull GenericServerSecretParams genericServerSecretParams,
@Nonnull Clock clock) {
this.accountsManager = accountsManager;
this.certificateGenerator = Objects.requireNonNull(certificateGenerator);
this.serverZkAuthOperations = Objects.requireNonNull(serverZkAuthOperations);
this.genericServerSecretParams = genericServerSecretParams;
@ -69,23 +75,25 @@ public class CertificateController {
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/delivery")
public DeliveryCertificate getDeliveryCertificate(@ReadOnly @Auth AuthenticatedDevice auth,
public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedDevice auth,
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164)
throws InvalidKeyException {
Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164))
.increment();
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return new DeliveryCertificate(
certificateGenerator.createFor(auth.getAccount(), auth.getAuthenticatedDevice(), includeE164));
certificateGenerator.createFor(account, auth.deviceId(), includeE164));
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/auth/group")
public GroupCredentials getGroupAuthenticationCredentials(
@ReadOnly @Auth AuthenticatedDevice auth,
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
@Auth AuthenticatedDevice auth,
@QueryParam("redemptionStartSeconds") long startSeconds,
@QueryParam("redemptionEndSeconds") long endSeconds) {
@ -102,13 +110,16 @@ public class CertificateController {
throw new BadRequestException();
}
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final List<GroupCredentials.GroupCredential> credentials = new ArrayList<>();
final List<GroupCredentials.CallLinkAuthCredential> callLinkAuthCredentials = new ArrayList<>();
Instant redemption = redemptionStart;
ServiceId.Aci aci = new ServiceId.Aci(auth.getAccount().getUuid());
ServiceId.Pni pni = new ServiceId.Pni(auth.getAccount().getPhoneNumberIdentifier());
final ServiceId.Aci aci = new ServiceId.Aci(account.getIdentifier(IdentityType.ACI));
final ServiceId.Pni pni = new ServiceId.Pni(account.getIdentifier(IdentityType.PNI));
while (!redemption.isAfter(redemptionEnd)) {
AuthCredentialWithPniResponse authCredentialWithPni = serverZkAuthOperations.issueAuthCredentialWithPniZkc(aci, pni, redemption);

View File

@ -25,6 +25,7 @@ import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
@ -40,12 +41,14 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker;
import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker.ChallengeConstraints;
import org.whispersystems.websocket.auth.ReadOnly;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
@Path("/v1/challenge")
@Tag(name = "Challenge")
public class ChallengeController {
private final AccountsManager accountsManager;
private final RateLimitChallengeManager rateLimitChallengeManager;
private final ChallengeConstraintChecker challengeConstraintChecker;
@ -53,8 +56,10 @@ public class ChallengeController {
private static final String CHALLENGE_TYPE_TAG = "type";
public ChallengeController(
final AccountsManager accountsManager,
final RateLimitChallengeManager rateLimitChallengeManager,
final ChallengeConstraintChecker challengeConstraintChecker) {
this.accountsManager = accountsManager;
this.rateLimitChallengeManager = rateLimitChallengeManager;
this.challengeConstraintChecker = challengeConstraintChecker;
}
@ -77,15 +82,18 @@ public class ChallengeController {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public Response handleChallengeResponse(@ReadOnly @Auth final AuthenticatedDevice auth,
public Response handleChallengeResponse(@Auth final AuthenticatedDevice auth,
@Valid final AnswerChallengeRequest answerRequest,
@Context ContainerRequestContext requestContext,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException {
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent));
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
requestContext, auth.getAccount());
requestContext, account);
try {
if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {
tags = tags.and(CHALLENGE_TYPE_TAG, "push");
@ -93,14 +101,14 @@ public class ChallengeController {
if (!constraints.pushPermitted()) {
return Response.status(429).build();
}
rateLimitChallengeManager.answerPushChallenge(auth.getAccount(), pushChallengeRequest.getChallenge());
rateLimitChallengeManager.answerPushChallenge(account, pushChallengeRequest.getChallenge());
} else if (answerRequest instanceof AnswerCaptchaChallengeRequest captchaChallengeRequest) {
tags = tags.and(CHALLENGE_TYPE_TAG, "captcha");
final String remoteAddress = (String) requestContext.getProperty(
RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
boolean success = rateLimitChallengeManager.answerCaptchaChallenge(
auth.getAccount(),
account,
captchaChallengeRequest.getCaptcha(),
remoteAddress,
userAgent,
@ -126,7 +134,7 @@ public class ChallengeController {
summary = "Request a push challenge",
description = """
Clients may proactively request a push challenge by making an empty POST request. Push challenges will only be
sent to the requesting accounts main device. When the push is received it may be provided as proof of completed
sent to the requesting accounts main device. When the push is received it may be provided as proof of completed
challenge to /v1/challenge.
APNs challenge payloads will be formatted as follows:
```
@ -140,12 +148,12 @@ public class ChallengeController {
"rateLimitChallenge": "{CHALLENGE_TOKEN}"
}
```
FCM challenge payloads will be formatted as follows:
FCM challenge payloads will be formatted as follows:
```
{"rateLimitChallenge": "{CHALLENGE_TOKEN}"}
```
Clients may retry the PUT in the event of an HTTP/5xx response (except HTTP/508) from the server, but must
Clients may retry the PUT in the event of an HTTP/5xx response (except HTTP/508) from the server, but must
implement an exponential back-off system and limit the total number of retries.
"""
)
@ -163,15 +171,18 @@ public class ChallengeController {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public Response requestPushChallenge(@ReadOnly @Auth final AuthenticatedDevice auth,
public Response requestPushChallenge(@Auth final AuthenticatedDevice auth,
@Context ContainerRequestContext requestContext) {
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
requestContext, auth.getAccount());
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(requestContext, account);
if (!constraints.pushPermitted()) {
return Response.status(429).build();
}
try {
rateLimitChallengeManager.sendPushChallenge(auth.getAccount());
rateLimitChallengeManager.sendPushChallenge(account);
return Response.status(200).build();
} catch (final NotPushRegisteredException e) {
return Response.status(404).build();

View File

@ -33,6 +33,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;
import org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException;
import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckKeyIdNotFoundException;
@ -41,7 +42,6 @@ import org.whispersystems.textsecuregcm.storage.devicecheck.DuplicatePublicKeyEx
import org.whispersystems.textsecuregcm.storage.devicecheck.RequestReuseException;
import org.whispersystems.textsecuregcm.storage.devicecheck.TooManyKeysException;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.websocket.auth.ReadOnly;
/**
* Process platform device attestations.
@ -55,6 +55,7 @@ import org.whispersystems.websocket.auth.ReadOnly;
public class DeviceCheckController {
private final Clock clock;
private final AccountsManager accountsManager;
private final BackupAuthManager backupAuthManager;
private final AppleDeviceCheckManager deviceCheckManager;
private final RateLimiters rateLimiters;
@ -63,12 +64,14 @@ public class DeviceCheckController {
public DeviceCheckController(
final Clock clock,
final AccountsManager accountsManager,
final BackupAuthManager backupAuthManager,
final AppleDeviceCheckManager deviceCheckManager,
final RateLimiters rateLimiters,
final long backupRedemptionLevel,
final Duration backupRedemptionDuration) {
this.clock = clock;
this.accountsManager = accountsManager;
this.backupAuthManager = backupAuthManager;
this.deviceCheckManager = deviceCheckManager;
this.backupRedemptionLevel = backupRedemptionLevel;
@ -94,14 +97,17 @@ public class DeviceCheckController {
@ApiResponse(responseCode = "200", description = "The response body includes a challenge")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
@ManagedAsync
public ChallengeResponse attestChallenge(@ReadOnly @Auth AuthenticatedDevice authenticatedDevice)
public ChallengeResponse attestChallenge(@Auth AuthenticatedDevice authenticatedDevice)
throws RateLimitExceededException {
rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)
.validate(authenticatedDevice.getAccount().getUuid());
.validate(authenticatedDevice.accountIdentifier());
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return new ChallengeResponse(deviceCheckManager.createChallenge(
AppleDeviceCheckManager.ChallengeType.ATTEST,
authenticatedDevice.getAccount()));
account));
}
@PUT
@ -125,7 +131,7 @@ public class DeviceCheckController {
@ApiResponse(responseCode = "409", description = "The provided keyId has already been registered to a different account")
@ManagedAsync
public void attest(
@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
@Auth final AuthenticatedDevice authenticatedDevice,
@Valid
@NotNull
@ -135,8 +141,11 @@ public class DeviceCheckController {
@RequestBody(description = "The attestation data, created by [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:))")
@NotNull final byte[] attestation) {
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
try {
deviceCheckManager.registerAttestation(authenticatedDevice.getAccount(), parseKeyId(keyId), attestation);
deviceCheckManager.registerAttestation(account, parseKeyId(keyId), attestation);
} catch (TooManyKeysException e) {
throw new WebApplicationException(Response.status(413).build());
} catch (ChallengeNotFoundException e) {
@ -166,17 +175,19 @@ public class DeviceCheckController {
@ApiResponse(responseCode = "429", description = "Ratelimited.")
@ManagedAsync
public ChallengeResponse assertChallenge(
@ReadOnly @Auth AuthenticatedDevice authenticatedDevice,
@Auth AuthenticatedDevice authenticatedDevice,
@Parameter(schema = @Schema(description = "The type of action you will make an assertion for",
allowableValues = {"backup"},
implementation = String.class))
@QueryParam("action") Action action) throws RateLimitExceededException {
rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)
.validate(authenticatedDevice.getAccount().getUuid());
return new ChallengeResponse(
deviceCheckManager.createChallenge(toChallengeType(action),
authenticatedDevice.getAccount()));
.validate(authenticatedDevice.accountIdentifier());
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return new ChallengeResponse(deviceCheckManager.createChallenge(toChallengeType(action), account));
}
@POST
@ -199,7 +210,7 @@ public class DeviceCheckController {
@ApiResponse(responseCode = "401", description = "The assertion could not be verified")
@ManagedAsync
public void assertion(
@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
@Auth final AuthenticatedDevice authenticatedDevice,
@Valid
@NotNull
@ -218,9 +229,12 @@ public class DeviceCheckController {
@RequestBody(description = "The assertion created by [generateAssertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:))")
@NotNull final byte[] assertion) {
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
try {
deviceCheckManager.validateAssert(
authenticatedDevice.getAccount(),
account,
parseKeyId(keyId),
toChallengeType(request.assertionRequest().action()),
request.assertionRequest().challenge(),
@ -237,7 +251,7 @@ public class DeviceCheckController {
// The request assertion was validated, execute it
switch (request.assertionRequest().action()) {
case BACKUP -> backupAuthManager.extendBackupVoucher(
authenticatedDevice.getAccount(),
account,
new Account.BackupVoucher(backupRedemptionLevel, clock.instant().plus(backupRedemptionDuration)))
.join();
}

View File

@ -34,7 +34,6 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.time.Duration;
@ -51,11 +50,9 @@ import java.util.concurrent.CompletionStage;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.glassfish.jersey.server.ContainerRequest;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.ChangesLinkedDevices;
import org.whispersystems.textsecuregcm.auth.LinkedDeviceRefreshRequirementProvider;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;
import org.whispersystems.textsecuregcm.entities.DeviceInfo;
@ -87,11 +84,10 @@ import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter;
import org.whispersystems.textsecuregcm.util.EnumMapUtil;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.LinkDeviceToken;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
import org.whispersystems.websocket.auth.Mutable;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v1/devices")
@Tag(name = "Devices")
@ -152,10 +148,10 @@ public class DeviceController {
@GET
@Produces(MediaType.APPLICATION_JSON)
public DeviceInfoList getDevices(@ReadOnly @Auth AuthenticatedDevice auth) {
public DeviceInfoList getDevices(@Auth AuthenticatedDevice auth) {
// Devices may change their own names (and primary devices may change the names of linked devices) and so the device
// state associated with the authenticated account may be stale. Fetch a fresh copy to compensate.
return accounts.getByAccountIdentifier(auth.getAccount().getIdentifier(IdentityType.ACI))
return accounts.getByAccountIdentifier(auth.accountIdentifier())
.map(account -> new DeviceInfoList(account.getDevices().stream()
.map(DeviceInfo::forDevice)
.toList()))
@ -166,9 +162,8 @@ public class DeviceController {
@Produces(MediaType.APPLICATION_JSON)
@Path("/{device_id}")
@ChangesLinkedDevices
public void removeDevice(@Mutable @Auth AuthenticatedDevice auth, @PathParam("device_id") byte deviceId) {
if (auth.getAuthenticatedDevice().getId() != Device.PRIMARY_ID &&
auth.getAuthenticatedDevice().getId() != deviceId) {
public void removeDevice(@Auth AuthenticatedDevice auth, @PathParam("device_id") byte deviceId) {
if (auth.deviceId() != Device.PRIMARY_ID && auth.deviceId() != deviceId) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
@ -176,13 +171,16 @@ public class DeviceController {
throw new ForbiddenException();
}
accounts.removeDevice(auth.getAccount(), deviceId).join();
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
accounts.removeDevice(account, deviceId).join();
}
/**
* Generates a signed device-linking token. Generally, primary devices will include the signed device-linking token in
* a provisioning message to a new device, and then the new device will include the token in its request to
* {@link #linkDevice(BasicAuthorizationHeader, String, LinkDeviceRequest, ContainerRequest)}.
* {@link #linkDevice(BasicAuthorizationHeader, String, LinkDeviceRequest)}.
*
* @param auth the authenticated account/device
*
@ -207,10 +205,11 @@ public class DeviceController {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public LinkDeviceToken createDeviceToken(@ReadOnly @Auth AuthenticatedDevice auth)
public LinkDeviceToken createDeviceToken(@Auth AuthenticatedDevice auth)
throws RateLimitExceededException, DeviceLimitExceededException {
final Account account = auth.getAccount();
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
rateLimiters.getAllocateDeviceLimiter().validate(account.getUuid());
@ -224,7 +223,7 @@ public class DeviceController {
throw new DeviceLimitExceededException(account.getDevices().size(), maxDeviceLimit);
}
if (auth.getAuthenticatedDevice().getId() != Device.PRIMARY_ID) {
if (auth.deviceId() != Device.PRIMARY_ID) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
@ -252,8 +251,7 @@ public class DeviceController {
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public LinkDeviceResponse linkDevice(@HeaderParam(HttpHeaders.AUTHORIZATION) BasicAuthorizationHeader authorizationHeader,
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable String userAgent,
@NotNull @Valid LinkDeviceRequest linkDeviceRequest,
@Context ContainerRequest containerRequest)
@NotNull @Valid LinkDeviceRequest linkDeviceRequest)
throws RateLimitExceededException, DeviceLimitExceededException {
final Account account = accounts.checkDeviceLinkingToken(linkDeviceRequest.verificationCode())
@ -279,11 +277,6 @@ public class DeviceController {
throw new WebApplicationException(Response.status(422).build());
}
// Normally, the "do we need to refresh somebody's websockets" listener can do this on its own. In this case,
// we're not using the conventional authentication system, and so we need to give it a hint so it knows who the
// active user is and what their device states look like.
LinkedDeviceRefreshRequirementProvider.setAccount(containerRequest, account);
final int maxDeviceLimit = maxDeviceConfiguration.getOrDefault(account.getNumber(), MAX_DEVICES);
if (account.getDevices().size() >= maxDeviceLimit) {
@ -351,7 +344,7 @@ public class DeviceController {
@ApiResponse(responseCode = "400", description = "The given token identifier or timeout was invalid")
@ApiResponse(responseCode = "429", description = "Rate-limited; try again after the prescribed delay")
public CompletionStage<Response> waitForLinkedDevice(
@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
@Auth final AuthenticatedDevice authenticatedDevice,
@PathParam("tokenIdentifier")
@Schema(description = "A 'link device' token identifier provided by the 'create link device token' endpoint")
@ -374,12 +367,18 @@ public class DeviceController {
final AtomicInteger linkedDeviceListenerCounter = getCounterForLinkedDeviceListeners(userAgent);
linkedDeviceListenerCounter.incrementAndGet();
return rateLimiters.getWaitForLinkedDeviceLimiter()
.validateAsync(authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI))
.thenCompose(ignored -> persistentTimer.start(WAIT_FOR_LINKED_DEVICE_TIMER_NAMESPACE, tokenIdentifier))
.thenCompose(sample -> accounts.waitForNewLinkedDevice(
authenticatedDevice.getAccount().getUuid(),
authenticatedDevice.getAuthenticatedDevice(),
return rateLimiters.getWaitForLinkedDeviceLimiter().validateAsync(authenticatedDevice.accountIdentifier())
.thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.thenCompose(maybeAccount -> {
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return persistentTimer.start(WAIT_FOR_LINKED_DEVICE_TIMER_NAMESPACE, tokenIdentifier)
.thenApply(sample -> new Pair<>(account, sample));
})
.thenCompose(accountAndSample -> accounts.waitForNewLinkedDevice(
authenticatedDevice.accountIdentifier(),
accountAndSample.first().getDevice(authenticatedDevice.deviceId())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)),
tokenIdentifier,
Duration.ofSeconds(timeoutSeconds))
.thenApply(maybeDeviceInfo -> maybeDeviceInfo
@ -391,7 +390,7 @@ public class DeviceController {
linkedDeviceListenerCounter.decrementAndGet();
if (response != null && response.getStatus() == Response.Status.OK.getStatusCode()) {
sample.stop(Timer.builder(WAIT_FOR_LINKED_DEVICE_TIMER_NAME)
accountAndSample.second().stop(Timer.builder(WAIT_FOR_LINKED_DEVICE_TIMER_NAME)
.publishPercentileHistogram(true)
.tags(Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.register(Metrics.globalRegistry));
@ -410,14 +409,15 @@ public class DeviceController {
@PUT
@Produces(MediaType.APPLICATION_JSON)
@Path("/capabilities")
public void setCapabilities(@Mutable @Auth final AuthenticatedDevice auth,
public void setCapabilities(@Auth final AuthenticatedDevice auth,
@NotNull
final Map<String, Boolean> capabilities) {
assert (auth.getAuthenticatedDevice() != null);
final byte deviceId = auth.getAuthenticatedDevice().getId();
accounts.updateDevice(auth.getAccount(), deviceId,
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
accounts.updateDevice(account, auth.deviceId(),
d -> d.setCapabilities(DeviceCapabilityAdapter.mapToSet(capabilities)));
}
@ -435,12 +435,13 @@ public class DeviceController {
@ApiResponse(responseCode = "200", description = "Public key stored successfully")
@ApiResponse(responseCode = "401", description = "Account authentication check failed")
@ApiResponse(responseCode = "422", description = "Invalid request format")
public CompletableFuture<Void> setPublicKey(@Mutable @Auth final AuthenticatedDevice auth,
public CompletableFuture<Void> setPublicKey(@Auth final AuthenticatedDevice auth,
final SetPublicKeyRequest setPublicKeyRequest) {
return clientPublicKeysManager.setPublicKey(auth.getAccount(),
auth.getAuthenticatedDevice().getId(),
setPublicKeyRequest.publicKey());
final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return clientPublicKeysManager.setPublicKey(account, auth.deviceId(), setPublicKeyRequest.publicKey());
}
private static boolean isCapabilityDowngrade(final Account account, final Set<DeviceCapability> capabilities) {
@ -531,15 +532,21 @@ public class DeviceController {
@ApiResponse(responseCode = "204", description = "Success")
@ApiResponse(responseCode = "422", description = "The request object could not be parsed or was otherwise invalid")
@ApiResponse(responseCode = "429", description = "Rate-limited; try again after the prescribed delay")
public CompletionStage<Void> recordTransferArchiveUploaded(@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
public CompletionStage<Void> recordTransferArchiveUploaded(@Auth final AuthenticatedDevice authenticatedDevice,
@NotNull @Valid final TransferArchiveUploadedRequest transferArchiveUploadedRequest) {
return rateLimiters.getUploadTransferArchiveLimiter()
.validateAsync(authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI))
.thenCompose(ignored -> accounts.recordTransferArchiveUpload(authenticatedDevice.getAccount(),
transferArchiveUploadedRequest.destinationDeviceId(),
Instant.ofEpochMilli(transferArchiveUploadedRequest.destinationDeviceCreated()),
transferArchiveUploadedRequest.transferArchive()));
.validateAsync(authenticatedDevice.accountIdentifier())
.thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.thenCompose(maybeAccount -> {
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return accounts.recordTransferArchiveUpload(account,
transferArchiveUploadedRequest.destinationDeviceId(),
Instant.ofEpochMilli(transferArchiveUploadedRequest.destinationDeviceCreated()),
transferArchiveUploadedRequest.transferArchive());
});
}
@GET
@ -558,7 +565,7 @@ public class DeviceController {
@ApiResponse(responseCode = "204", description = "No transfer archive was uploaded before the call completed; clients may repeat the call to continue waiting")
@ApiResponse(responseCode = "400", description = "The given timeout was invalid")
@ApiResponse(responseCode = "429", description = "Rate-limited; try again after the prescribed delay")
public CompletionStage<Response> waitForTransferArchive(@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
public CompletionStage<Response> waitForTransferArchive(@Auth final AuthenticatedDevice authenticatedDevice,
@QueryParam("timeout")
@DefaultValue("30")
@ -575,24 +582,30 @@ public class DeviceController {
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable String userAgent) {
final String rateLimiterKey = authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI) +
":" + authenticatedDevice.getAuthenticatedDevice().getId();
final String rateLimiterKey = authenticatedDevice.accountIdentifier() + ":" + authenticatedDevice.deviceId();
return rateLimiters.getWaitForTransferArchiveLimiter().validateAsync(rateLimiterKey)
.thenCompose(ignored -> persistentTimer.start(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAMESPACE, rateLimiterKey))
.thenCompose(sample -> accounts.waitForTransferArchive(authenticatedDevice.getAccount(),
authenticatedDevice.getAuthenticatedDevice(),
.thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.thenCompose(maybeAccount -> {
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
return persistentTimer.start(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAMESPACE, rateLimiterKey)
.thenApply(sample -> new Pair<>(account, sample));
})
.thenCompose(accountAndSample -> accounts.waitForTransferArchive(accountAndSample.first(),
accountAndSample.first().getDevice(authenticatedDevice.deviceId())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)),
Duration.ofSeconds(timeoutSeconds))
.thenApply(maybeTransferArchive -> maybeTransferArchive
.map(transferArchive -> Response.status(Response.Status.OK).entity(transferArchive).build())
.orElseGet(() -> Response.status(Response.Status.NO_CONTENT).build()))
.whenComplete((response, throwable) -> {
if (response != null && response.getStatus() == Response.Status.OK.getStatusCode()) {
sample.stop(Timer.builder(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAME)
accountAndSample.second().stop(Timer.builder(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAME)
.publishPercentileHistogram(true)
.tags(Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
primaryPlatformTag(authenticatedDevice.getAccount())))
primaryPlatformTag(accountAndSample.first())))
.register(Metrics.globalRegistry));
}
}));

View File

@ -14,12 +14,10 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.Clock;
import java.util.UUID;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v2/directory")
@Tag(name = "Directory")
@ -57,8 +55,7 @@ public class DirectoryV2Controller {
"""
)
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
public ExternalServiceCredentials getAuthToken(final @ReadOnly @Auth AuthenticatedDevice auth) {
final UUID uuid = auth.getAccount().getUuid();
return directoryServiceTokenGenerator.generateForUuid(uuid);
public ExternalServiceCredentials getAuthToken(final @Auth AuthenticatedDevice auth) {
return directoryServiceTokenGenerator.generateForUuid(auth.accountIdentifier());
}
}

View File

@ -15,6 +15,7 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
@ -33,10 +34,10 @@ import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.websocket.auth.Mutable;
@Path("/v1/donation")
@Tag(name = "Donations")
@ -86,7 +87,7 @@ public class DonationController {
""")
@ApiResponse(responseCode = "429", description = "Rate limited.")
public CompletionStage<Response> redeemReceipt(
@Mutable @Auth final AuthenticatedDevice auth,
@Auth final AuthenticatedDevice auth,
@NotNull @Valid final RedeemReceiptRequest request) {
return CompletableFuture.supplyAsync(() -> {
ReceiptCredentialPresentation receiptCredentialPresentation;
@ -118,23 +119,29 @@ public class DonationController {
.type(MediaType.TEXT_PLAIN_TYPE)
.build());
}
return redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.getAccount().getUuid())
.thenCompose(receiptMatched -> {
if (!receiptMatched) {
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
.entity("receipt serial is already redeemed")
.type(MediaType.TEXT_PLAIN_TYPE)
.build());
}
return accountsManager.updateAsync(auth.getAccount(), a -> {
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
if (request.isPrimary()) {
a.makeBadgePrimaryIfExists(clock, badgeId);
return accountsManager.getByAccountIdentifierAsync(auth.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
return redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.accountIdentifier())
.thenCompose(receiptMatched -> {
if (!receiptMatched) {
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
.entity("receipt serial is already redeemed")
.type(MediaType.TEXT_PLAIN_TYPE)
.build());
}
})
.thenApply(ignored -> Response.ok().build());
return accountsManager.updateAsync(account, a -> {
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
if (request.isPrimary()) {
a.makeBadgePrimaryIfExists(clock, badgeId);
}
})
.thenApply(ignored -> Response.ok().build());
});
});
}).thenCompose(Function.identity());
}

View File

@ -5,9 +5,8 @@
package org.whispersystems.textsecuregcm.controllers;
import org.whispersystems.textsecuregcm.auth.TurnToken;
import java.util.List;
import org.whispersystems.textsecuregcm.auth.TurnToken;
public record GetCallingRelaysResponse(List<TurnToken> relays) {
}

View File

@ -23,7 +23,6 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.WebSocketConnectionEventManager;
import org.whispersystems.websocket.auth.ReadOnly;
import org.whispersystems.websocket.session.WebSocketSession;
import org.whispersystems.websocket.session.WebSocketSessionContext;
@ -45,16 +44,16 @@ public class KeepAliveController {
}
@GET
public Response getKeepAlive(@ReadOnly @Auth Optional<AuthenticatedDevice> maybeAuth,
public Response getKeepAlive(@Auth Optional<AuthenticatedDevice> maybeAuth,
@WebSocketSession WebSocketSessionContext context) {
maybeAuth.ifPresent(auth -> {
if (!webSocketConnectionEventManager.isLocallyPresent(auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId())) {
if (!webSocketConnectionEventManager.isLocallyPresent(auth.accountIdentifier(), auth.deviceId())) {
final Duration age = Duration.between(context.getClient().getCreated(), Instant.now());
logger.debug("***** No local subscription found for {}::{}; age = {}ms, User-Agent = {}",
auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId(), age.toMillis(),
auth.accountIdentifier(), auth.deviceId(), age.toMillis(),
context.getClient().getUserAgent());
context.getClient().close(1000, "OK");

View File

@ -31,8 +31,7 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletionException;
import org.glassfish.jersey.server.ManagedAsync;
import org.signal.keytransparency.client.AciMonitorRequest;
import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
@ -48,16 +47,12 @@ import org.whispersystems.textsecuregcm.entities.KeyTransparencySearchResponse;
import org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v1/key-transparency")
@Tag(name = "KeyTransparency")
public class KeyTransparencyController {
private static final Logger LOGGER = LoggerFactory.getLogger(KeyTransparencyController.class);
@VisibleForTesting
static final Duration KEY_TRANSPARENCY_RPC_TIMEOUT = Duration.ofSeconds(15);
private final KeyTransparencyServiceClient keyTransparencyServiceClient;
public KeyTransparencyController(
@ -89,8 +84,9 @@ public class KeyTransparencyController {
@Path("/search")
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_SEARCH_PER_IP)
@Produces(MediaType.APPLICATION_JSON)
@ManagedAsync
public KeyTransparencySearchResponse search(
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final KeyTransparencySearchRequest request) {
// Disallow clients from making authenticated requests to this endpoint
@ -105,19 +101,16 @@ public class KeyTransparencyController {
.build()
));
return keyTransparencyServiceClient.search(
return new KeyTransparencySearchResponse(
keyTransparencyServiceClient.search(
ByteString.copyFrom(request.aci().toCompactByteArray()),
ByteString.copyFrom(request.aciIdentityKey().serialize()),
request.usernameHash().map(ByteString::copyFrom),
maybeE164SearchRequest,
request.lastTreeHeadSize(),
request.distinguishedTreeHeadSize(),
KEY_TRANSPARENCY_RPC_TIMEOUT)
.thenApply(KeyTransparencySearchResponse::new).join();
} catch (final CancellationException exception) {
LOGGER.error("Unexpected cancellation from key transparency service", exception);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, exception);
} catch (final CompletionException exception) {
request.distinguishedTreeHeadSize())
.toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}
// This is unreachable
@ -141,8 +134,9 @@ public class KeyTransparencyController {
@Path("/monitor")
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_MONITOR_PER_IP)
@Produces(MediaType.APPLICATION_JSON)
@ManagedAsync
public KeyTransparencyMonitorResponse monitor(
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final KeyTransparencyMonitorRequest request) {
// Disallow clients from making authenticated requests to this endpoint
@ -174,13 +168,9 @@ public class KeyTransparencyController {
usernameHashMonitorRequest,
e164MonitorRequest,
request.lastNonDistinguishedTreeHeadSize(),
request.lastDistinguishedTreeHeadSize(),
KEY_TRANSPARENCY_RPC_TIMEOUT).join());
} catch (final CancellationException exception) {
LOGGER.error("Unexpected cancellation from key transparency service", exception);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, exception);
} catch (final CompletionException exception) {
request.lastDistinguishedTreeHeadSize())
.toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}
// This is unreachable
@ -203,8 +193,9 @@ public class KeyTransparencyController {
@Path("/distinguished")
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_DISTINGUISHED_PER_IP)
@Produces(MediaType.APPLICATION_JSON)
@ManagedAsync
public KeyTransparencyDistinguishedKeyResponse getDistinguishedKey(
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Parameter(description = "The distinguished tree head size returned by a previously verified call")
@QueryParam("lastTreeHeadSize") @Valid final Optional<@Positive Long> lastTreeHeadSize) {
@ -213,34 +204,28 @@ public class KeyTransparencyController {
requireNotAuthenticated(authenticatedAccount);
try {
return keyTransparencyServiceClient.getDistinguishedKey(lastTreeHeadSize, KEY_TRANSPARENCY_RPC_TIMEOUT)
.thenApply(KeyTransparencyDistinguishedKeyResponse::new)
.join();
} catch (final CancellationException exception) {
LOGGER.error("Unexpected cancellation from key transparency service", exception);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, exception);
} catch (final CompletionException exception) {
return new KeyTransparencyDistinguishedKeyResponse(
keyTransparencyServiceClient.getDistinguishedKey(lastTreeHeadSize)
.toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}
// This is unreachable
return null;
}
private void handleKeyTransparencyServiceError(final CompletionException exception) {
final Throwable unwrapped = ExceptionUtils.unwrap(exception);
if (unwrapped instanceof StatusRuntimeException e) {
final Status.Code code = e.getStatus().getCode();
final String description = e.getStatus().getDescription();
switch (code) {
case NOT_FOUND -> throw new NotFoundException(description);
case PERMISSION_DENIED -> throw new ForbiddenException(description);
case INVALID_ARGUMENT -> throw new WebApplicationException(description, 422);
default -> throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, unwrapped);
private void handleKeyTransparencyServiceError(final StatusRuntimeException exception) {
final Status.Code code = exception.getStatus().getCode();
final String description = exception.getStatus().getDescription();
switch (code) {
case NOT_FOUND -> throw new NotFoundException(description);
case PERMISSION_DENIED -> throw new ForbiddenException(description);
case INVALID_ARGUMENT -> throw new WebApplicationException(description, 422);
default -> {
LOGGER.error("Unexpected error calling key transparency service", exception);
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, exception);
}
}
LOGGER.error("Unexpected key transparency service failure", unwrapped);
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, unwrapped);
}
private void requireNotAuthenticated(final Optional<AuthenticatedDevice> authenticatedAccount) {

View File

@ -74,7 +74,6 @@ import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.KeysManager;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.websocket.auth.ReadOnly;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v2/keys")
@ -111,16 +110,21 @@ public class KeysController {
description = "Gets the number of one-time prekeys uploaded for this device and still available")
@ApiResponse(responseCode = "200", description = "Body contains the number of available one-time prekeys for the device.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
public CompletableFuture<PreKeyCount> getStatus(@ReadOnly @Auth final AuthenticatedDevice auth,
public CompletableFuture<PreKeyCount> getStatus(@Auth final AuthenticatedDevice auth,
@QueryParam("identity") @DefaultValue("aci") final IdentityType identityType) {
final CompletableFuture<Integer> ecCountFuture =
keysManager.getEcCount(auth.getAccount().getIdentifier(identityType), auth.getAuthenticatedDevice().getId());
return accounts.getByAccountIdentifierAsync(auth.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final CompletableFuture<Integer> pqCountFuture =
keysManager.getPqCount(auth.getAccount().getIdentifier(identityType), auth.getAuthenticatedDevice().getId());
final CompletableFuture<Integer> ecCountFuture =
keysManager.getEcCount(account.getIdentifier(identityType), auth.deviceId());
return ecCountFuture.thenCombine(pqCountFuture, PreKeyCount::new);
final CompletableFuture<Integer> pqCountFuture =
keysManager.getPqCount(account.getIdentifier(identityType), auth.deviceId());
return ecCountFuture.thenCombine(pqCountFuture, PreKeyCount::new);
});
}
@PUT
@ -132,7 +136,7 @@ public class KeysController {
@ApiResponse(responseCode = "403", description = "Attempt to change identity key from a non-primary device.")
@ApiResponse(responseCode = "422", description = "Invalid request format.")
public CompletableFuture<Response> setKeys(
@ReadOnly @Auth final AuthenticatedDevice auth,
@Auth final AuthenticatedDevice auth,
@RequestBody @NotNull @Valid final SetKeysRequest setKeysRequest,
@Parameter(allowEmptyValue=true)
@ -143,63 +147,70 @@ public class KeysController {
@QueryParam("identity") @DefaultValue("aci") final IdentityType identityType,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
final Account account = auth.getAccount();
final Device device = auth.getAuthenticatedDevice();
final UUID identifier = account.getIdentifier(identityType);
return accounts.getByAccountIdentifierAsync(auth.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
checkSignedPreKeySignatures(setKeysRequest, account.getIdentityKey(identityType), userAgent);
final Device device = account.getDevice(auth.deviceId())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);
final Tag primaryDeviceTag = Tag.of(PRIMARY_DEVICE_TAG_NAME, String.valueOf(auth.getAuthenticatedDevice().isPrimary()));
final Tag identityTypeTag = Tag.of(IDENTITY_TYPE_TAG_NAME, identityType.name());
final UUID identifier = account.getIdentifier(identityType);
final List<CompletableFuture<Void>> storeFutures = new ArrayList<>(4);
checkSignedPreKeySignatures(setKeysRequest, account.getIdentityKey(identityType), userAgent);
if (!setKeysRequest.preKeys().isEmpty()) {
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec"));
final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);
final Tag primaryDeviceTag = Tag.of(PRIMARY_DEVICE_TAG_NAME, String.valueOf(auth.deviceId() == Device.PRIMARY_ID));
final Tag identityTypeTag = Tag.of(IDENTITY_TYPE_TAG_NAME, identityType.name());
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
final List<CompletableFuture<Void>> storeFutures = new ArrayList<>(4);
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
.tags(tags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
.record(setKeysRequest.preKeys().size());
if (!setKeysRequest.preKeys().isEmpty()) {
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec"));
storeFutures.add(keysManager.storeEcOneTimePreKeys(identifier, device.getId(), setKeysRequest.preKeys()));
}
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
if (setKeysRequest.signedPreKey() != null) {
Metrics.counter(STORE_KEYS_COUNTER_NAME,
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec-signed")))
.increment();
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
.tags(tags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
.record(setKeysRequest.preKeys().size());
storeFutures.add(keysManager.storeEcSignedPreKeys(identifier, device.getId(), setKeysRequest.signedPreKey()));
}
storeFutures.add(keysManager.storeEcOneTimePreKeys(identifier, device.getId(), setKeysRequest.preKeys()));
}
if (!setKeysRequest.pqPreKeys().isEmpty()) {
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber"));
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
if (setKeysRequest.signedPreKey() != null) {
Metrics.counter(STORE_KEYS_COUNTER_NAME,
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec-signed")))
.increment();
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
.tags(tags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
.record(setKeysRequest.pqPreKeys().size());
storeFutures.add(keysManager.storeEcSignedPreKeys(identifier, device.getId(), setKeysRequest.signedPreKey()));
}
storeFutures.add(keysManager.storeKemOneTimePreKeys(identifier, device.getId(), setKeysRequest.pqPreKeys()));
}
if (!setKeysRequest.pqPreKeys().isEmpty()) {
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber"));
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
if (setKeysRequest.pqLastResortPreKey() != null) {
Metrics.counter(STORE_KEYS_COUNTER_NAME,
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber-last-resort")))
.increment();
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
.tags(tags)
.publishPercentileHistogram()
.register(Metrics.globalRegistry)
.record(setKeysRequest.pqPreKeys().size());
storeFutures.add(keysManager.storePqLastResort(identifier, device.getId(), setKeysRequest.pqLastResortPreKey()));
}
storeFutures.add(keysManager.storeKemOneTimePreKeys(identifier, device.getId(), setKeysRequest.pqPreKeys()));
}
return CompletableFuture.allOf(storeFutures.toArray(EMPTY_FUTURE_ARRAY))
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
if (setKeysRequest.pqLastResortPreKey() != null) {
Metrics.counter(STORE_KEYS_COUNTER_NAME,
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber-last-resort")))
.increment();
storeFutures.add(keysManager.storePqLastResort(identifier, device.getId(), setKeysRequest.pqLastResortPreKey()));
}
return CompletableFuture.allOf(storeFutures.toArray(EMPTY_FUTURE_ARRAY))
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
});
}
private void checkSignedPreKeySignatures(final SetKeysRequest setKeysRequest,
@ -253,64 +264,69 @@ public class KeysController {
""")
@ApiResponse(responseCode = "422", description = "Invalid request format")
public CompletableFuture<Response> checkKeys(
@ReadOnly @Auth final AuthenticatedDevice auth,
@Auth final AuthenticatedDevice auth,
@RequestBody @NotNull @Valid final CheckKeysRequest checkKeysRequest) {
final UUID identifier = auth.getAccount().getIdentifier(checkKeysRequest.identityType());
final byte deviceId = auth.getAuthenticatedDevice().getId();
return accounts.getByAccountIdentifierAsync(auth.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final CompletableFuture<Optional<ECSignedPreKey>> ecSignedPreKeyFuture =
keysManager.getEcSignedPreKey(identifier, deviceId);
final UUID identifier = account.getIdentifier(checkKeysRequest.identityType());
final byte deviceId = auth.deviceId();
final CompletableFuture<Optional<KEMSignedPreKey>> lastResortKeyFuture =
keysManager.getLastResort(identifier, deviceId);
final CompletableFuture<Optional<ECSignedPreKey>> ecSignedPreKeyFuture =
keysManager.getEcSignedPreKey(identifier, deviceId);
return CompletableFuture.allOf(ecSignedPreKeyFuture, lastResortKeyFuture)
.thenApply(ignored -> {
final Optional<ECSignedPreKey> maybeSignedPreKey = ecSignedPreKeyFuture.join();
final Optional<KEMSignedPreKey> maybeLastResortKey = lastResortKeyFuture.join();
final CompletableFuture<Optional<KEMSignedPreKey>> lastResortKeyFuture =
keysManager.getLastResort(identifier, deviceId);
final boolean digestsMatch;
return CompletableFuture.allOf(ecSignedPreKeyFuture, lastResortKeyFuture)
.thenApply(ignored -> {
final Optional<ECSignedPreKey> maybeSignedPreKey = ecSignedPreKeyFuture.join();
final Optional<KEMSignedPreKey> maybeLastResortKey = lastResortKeyFuture.join();
if (maybeSignedPreKey.isPresent() && maybeLastResortKey.isPresent()) {
final IdentityKey identityKey = auth.getAccount().getIdentityKey(checkKeysRequest.identityType());
final ECSignedPreKey ecSignedPreKey = maybeSignedPreKey.get();
final KEMSignedPreKey lastResortKey = maybeLastResortKey.get();
final boolean digestsMatch;
final MessageDigest messageDigest;
if (maybeSignedPreKey.isPresent() && maybeLastResortKey.isPresent()) {
final IdentityKey identityKey = account.getIdentityKey(checkKeysRequest.identityType());
final ECSignedPreKey ecSignedPreKey = maybeSignedPreKey.get();
final KEMSignedPreKey lastResortKey = maybeLastResortKey.get();
try {
messageDigest = MessageDigest.getInstance("SHA-256");
} catch (final NoSuchAlgorithmException e) {
throw new AssertionError("Every implementation of the Java platform is required to support SHA-256", e);
}
final MessageDigest messageDigest;
messageDigest.update(identityKey.serialize());
try {
messageDigest = MessageDigest.getInstance("SHA-256");
} catch (final NoSuchAlgorithmException e) {
throw new AssertionError("Every implementation of the Java platform is required to support SHA-256", e);
}
{
final ByteBuffer ecSignedPreKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
ecSignedPreKeyIdBuffer.putLong(ecSignedPreKey.keyId());
ecSignedPreKeyIdBuffer.flip();
messageDigest.update(identityKey.serialize());
messageDigest.update(ecSignedPreKeyIdBuffer);
messageDigest.update(ecSignedPreKey.serializedPublicKey());
}
{
final ByteBuffer ecSignedPreKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
ecSignedPreKeyIdBuffer.putLong(ecSignedPreKey.keyId());
ecSignedPreKeyIdBuffer.flip();
{
final ByteBuffer lastResortKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
lastResortKeyIdBuffer.putLong(lastResortKey.keyId());
lastResortKeyIdBuffer.flip();
messageDigest.update(ecSignedPreKeyIdBuffer);
messageDigest.update(ecSignedPreKey.serializedPublicKey());
}
messageDigest.update(lastResortKeyIdBuffer);
messageDigest.update(lastResortKey.serializedPublicKey());
}
{
final ByteBuffer lastResortKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
lastResortKeyIdBuffer.putLong(lastResortKey.keyId());
lastResortKeyIdBuffer.flip();
digestsMatch = MessageDigest.isEqual(messageDigest.digest(), checkKeysRequest.digest());
} else {
digestsMatch = false;
}
messageDigest.update(lastResortKeyIdBuffer);
messageDigest.update(lastResortKey.serializedPublicKey());
}
return Response.status(digestsMatch ? Response.Status.OK : Response.Status.CONFLICT).build();
digestsMatch = MessageDigest.isEqual(messageDigest.digest(), checkKeysRequest.digest());
} else {
digestsMatch = false;
}
return Response.status(digestsMatch ? Response.Status.OK : Response.Status.CONFLICT).build();
});
});
}
@ -327,7 +343,7 @@ public class KeysController {
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public PreKeyResponse getDeviceKeys(
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
@HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,
@ -340,15 +356,18 @@ public class KeysController {
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
throws RateLimitExceededException {
if (auth.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) {
if (maybeAuthenticatedDevice.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
final Optional<Account> account = auth.map(AuthenticatedDevice::getAccount);
final Optional<Account> account = maybeAuthenticatedDevice
.map(authenticatedDevice -> accounts.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
final Optional<Account> maybeTarget = accounts.getByServiceIdentifier(targetIdentifier);
if (groupSendToken.isPresent()) {
if (auth.isPresent() || accessKey.isPresent()) {
if (maybeAuthenticatedDevice.isPresent() || accessKey.isPresent()) {
throw new BadRequestException();
}
try {
@ -364,7 +383,7 @@ public class KeysController {
if (account.isPresent()) {
rateLimiters.getPreKeysLimiter().validate(
account.get().getUuid() + "." + auth.get().getAuthenticatedDevice().getId() + "__" + targetIdentifier.uuid()
account.get().getUuid() + "." + maybeAuthenticatedDevice.get().deviceId() + "__" + targetIdentifier.uuid()
+ "." + deviceId);
}

View File

@ -105,6 +105,7 @@ import org.whispersystems.textsecuregcm.spam.SpamChecker;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
@ -112,7 +113,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
import org.whispersystems.websocket.WebsocketHeaders;
import org.whispersystems.websocket.auth.ReadOnly;
import reactor.core.scheduler.Scheduler;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@ -236,7 +236,7 @@ public class MessageController {
@ApiResponse(
responseCode="428",
description="The sender should complete a challenge before proceeding")
public Response sendMessage(@ReadOnly @Auth final Optional<AuthenticatedDevice> source,
public Response sendMessage(@Auth final Optional<AuthenticatedDevice> source,
@Parameter(description="The recipient's unidentified access key")
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) final Optional<Anonymous> accessKey,
@ -274,12 +274,14 @@ public class MessageController {
sendStoryMessage(destinationIdentifier, messages, context);
} else if (source.isPresent()) {
final AuthenticatedDevice authenticatedDevice = source.get();
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
if (authenticatedDevice.getAccount().isIdentifiedBy(destinationIdentifier)) {
if (account.isIdentifiedBy(destinationIdentifier)) {
needsSync = false;
sendSyncMessage(source.get(), destinationIdentifier, messages, context);
sendSyncMessage(source.get(), account, destinationIdentifier, messages, context);
} else {
needsSync = authenticatedDevice.getAccount().getDevices().size() > 1;
needsSync = account.getDevices().size() > 1;
sendIdentifiedSenderIndividualMessage(authenticatedDevice, destinationIdentifier, messages, context);
}
} else {
@ -302,7 +304,7 @@ public class MessageController {
final Account destination =
accountsManager.getByServiceIdentifier(destinationIdentifier).orElseThrow(NotFoundException::new);
rateLimiters.getMessagesLimiter().validate(source.getAccount().getUuid(), destination.getUuid());
rateLimiters.getMessagesLimiter().validate(source.accountIdentifier(), destination.getUuid());
sendIndividualMessage(destination,
destinationIdentifier,
@ -314,6 +316,7 @@ public class MessageController {
}
private void sendSyncMessage(final AuthenticatedDevice source,
final Account sourceAccount,
final ServiceIdentifier destinationIdentifier,
final IncomingMessageList messages,
final ContainerRequestContext context)
@ -323,7 +326,7 @@ public class MessageController {
throw new WebApplicationException(Status.FORBIDDEN);
}
sendIndividualMessage(source.getAccount(),
sendIndividualMessage(sourceAccount,
destinationIdentifier,
source,
messages,
@ -420,8 +423,8 @@ public class MessageController {
try {
return message.toEnvelope(
destinationIdentifier,
sender != null ? sender.getAccount() : null,
sender != null ? sender.getAuthenticatedDevice().getId() : null,
sender != null ? new AciServiceIdentifier(sender.accountIdentifier()) : null,
sender != null ? sender.deviceId() : null,
messages.timestamp() == 0 ? System.currentTimeMillis() : messages.timestamp(),
isStory,
messages.online(),
@ -437,7 +440,7 @@ public class MessageController {
.collect(Collectors.toMap(IncomingMessage::destinationDeviceId, IncomingMessage::destinationRegistrationId));
final Optional<Byte> syncMessageSenderDeviceId = messageType == MessageType.SYNC
? Optional.ofNullable(sender).map(authenticatedDevice -> authenticatedDevice.getAuthenticatedDevice().getId())
? Optional.ofNullable(sender).map(AuthenticatedDevice::deviceId)
: Optional.empty();
try {
@ -755,31 +758,37 @@ public class MessageController {
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<OutgoingMessageEntityList> getPendingMessages(@ReadOnly @Auth AuthenticatedDevice auth,
public CompletableFuture<OutgoingMessageEntityList> getPendingMessages(@Auth AuthenticatedDevice auth,
@HeaderParam(WebsocketHeaders.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader,
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent) {
boolean shouldReceiveStories = WebsocketHeaders.parseReceiveStoriesHeader(receiveStoriesHeader);
return accountsManager.getByAccountIdentifierAsync(auth.accountIdentifier())
.thenCompose(maybeAccount -> {
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
final Device device = account.getDevice(auth.deviceId())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), userAgent);
final boolean shouldReceiveStories = WebsocketHeaders.parseReceiveStoriesHeader(receiveStoriesHeader);
return messagesManager.getMessagesForDevice(
auth.getAccount().getUuid(),
auth.getAuthenticatedDevice(),
false)
.map(messagesAndHasMore -> {
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
if (!shouldReceiveStories) {
envelopes = envelopes.filter(e -> !e.getStory());
}
pushNotificationManager.handleMessagesRetrieved(account, device, userAgent);
return messagesManager.getMessagesForDevice(
auth.accountIdentifier(),
device,
false)
.map(messagesAndHasMore -> {
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
if (!shouldReceiveStories) {
envelopes = envelopes.filter(e -> !e.getStory());
}
final OutgoingMessageEntityList messages = new OutgoingMessageEntityList(envelopes
.map(OutgoingMessageEntity::fromEnvelope)
.peek(outgoingMessageEntity -> {
messageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(), outgoingMessageEntity);
messageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageEntity);
messageMetrics.measureOutgoingMessageLatency(outgoingMessageEntity.serverTimestamp(),
"rest",
auth.getAuthenticatedDevice().isPrimary(),
auth.deviceId() == Device.PRIMARY_ID,
outgoingMessageEntity.urgent(),
// Messages fetched via this endpoint (as opposed to WebSocketConnection) are never ephemeral
// because, by definition, the client doesn't have a "live" connection via which to receive
@ -791,26 +800,27 @@ public class MessageController {
.collect(Collectors.toList()),
messagesAndHasMore.second());
Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.record(estimateMessageListSizeBytes(messages));
Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.record(estimateMessageListSizeBytes(messages));
if (!messages.messages().isEmpty()) {
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.getAccount().getIdentifier(IdentityType.ACI),
auth.getAuthenticatedDevice().getId(),
messages.messages().getFirst().guid(),
userAgent,
"rest");
}
if (!messages.messages().isEmpty()) {
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.accountIdentifier(),
auth.deviceId(),
messages.messages().getFirst().guid(),
userAgent,
"rest");
}
if (messagesAndHasMore.second()) {
pushNotificationScheduler.scheduleDelayedNotification(auth.getAccount(), auth.getAuthenticatedDevice(), NOTIFY_FOR_REMAINING_MESSAGES_DELAY);
}
if (messagesAndHasMore.second()) {
pushNotificationScheduler.scheduleDelayedNotification(account, device, NOTIFY_FOR_REMAINING_MESSAGES_DELAY);
}
return messages;
})
.timeout(Duration.ofSeconds(5))
.subscribeOn(messageDeliveryScheduler)
.toFuture();
return messages;
})
.timeout(Duration.ofSeconds(5))
.subscribeOn(messageDeliveryScheduler)
.toFuture();
});
}
private static long estimateMessageListSizeBytes(final OutgoingMessageEntityList messageList) {
@ -827,22 +837,27 @@ public class MessageController {
@Timed
@DELETE
@Path("/uuid/{uuid}")
public CompletableFuture<Response> removePendingMessage(@ReadOnly @Auth AuthenticatedDevice auth, @PathParam("uuid") UUID uuid) {
public CompletableFuture<Response> removePendingMessage(@Auth AuthenticatedDevice auth, @PathParam("uuid") UUID uuid) {
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
final Device device = account.getDevice(auth.deviceId())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
return messagesManager.delete(
auth.getAccount().getUuid(),
auth.getAuthenticatedDevice(),
auth.accountIdentifier(),
device,
uuid,
null)
.thenAccept(maybeRemovedMessage -> maybeRemovedMessage.ifPresent(removedMessage -> {
WebSocketConnection.recordMessageDeliveryDuration(removedMessage.serverTimestamp(),
auth.getAuthenticatedDevice());
WebSocketConnection.recordMessageDeliveryDuration(removedMessage.serverTimestamp(), device);
if (removedMessage.sourceServiceId().isPresent()
&& removedMessage.envelopeType() != Type.SERVER_DELIVERY_RECEIPT) {
if (removedMessage.sourceServiceId().get() instanceof AciServiceIdentifier aciServiceIdentifier) {
try {
receiptSender.sendReceipt(removedMessage.destinationServiceId(), auth.getAuthenticatedDevice().getId(),
receiptSender.sendReceipt(removedMessage.destinationServiceId(), auth.deviceId(),
aciServiceIdentifier, removedMessage.clientTimestamp());
} catch (Exception e) {
logger.warn("Failed to send delivery receipt", e);
@ -863,7 +878,7 @@ public class MessageController {
@Consumes(MediaType.APPLICATION_JSON)
@Path("/report/{source}/{messageGuid}")
public Response reportSpamMessage(
@ReadOnly @Auth AuthenticatedDevice auth,
@Auth AuthenticatedDevice auth,
@PathParam("source") String source,
@PathParam("messageGuid") UUID messageGuid,
@Nullable SpamReport spamReport,
@ -899,7 +914,7 @@ public class MessageController {
}
}
UUID spamReporterUuid = auth.getAccount().getUuid();
UUID spamReporterUuid = auth.accountIdentifier();
// spam report token is optional, but if provided ensure it is non-empty.
final Optional<byte[]> maybeSpamReportToken =

View File

@ -5,8 +5,8 @@
package org.whispersystems.textsecuregcm.controllers;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import java.util.Map;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
public class MultiRecipientMismatchedDevicesException extends Exception {

View File

@ -69,7 +69,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
import org.whispersystems.websocket.auth.ReadOnly;
/**
@ -163,7 +162,7 @@ public class OneTimeDonationController {
@StringToClassMapItem(key = "error", value = String.class)
})))
public CompletableFuture<Response> createBoostPaymentIntent(
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid CreateBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
@ -249,7 +248,7 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createPayPalBoost(
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid CreatePayPalBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@Context ContainerRequestContext containerRequestContext) {
@ -296,7 +295,7 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> confirmPayPalBoost(
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid ConfirmPayPalBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
@ -342,7 +341,7 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createBoostReceiptCredentials(
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreateBoostReceiptCredentialsRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {

View File

@ -17,7 +17,6 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v1/payments")
@Tag(name = "Payments")
@ -43,14 +42,14 @@ public class PaymentsController {
@GET
@Path("/auth")
@Produces(MediaType.APPLICATION_JSON)
public ExternalServiceCredentials getAuth(final @ReadOnly @Auth AuthenticatedDevice auth) {
return paymentsServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid());
public ExternalServiceCredentials getAuth(final @Auth AuthenticatedDevice auth) {
return paymentsServiceCredentialsGenerator.generateForUuid(auth.accountIdentifier());
}
@GET
@Path("/conversions")
@Produces(MediaType.APPLICATION_JSON)
public CurrencyConversionEntityList getConversions(final @ReadOnly @Auth AuthenticatedDevice auth) {
public CurrencyConversionEntityList getConversions(final @Auth AuthenticatedDevice auth) {
return currencyManager.getCurrencyConversions().orElseThrow();
}
}

View File

@ -26,6 +26,7 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
@ -94,8 +95,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.ProfileHelper;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.websocket.auth.Mutable;
import org.whispersystems.websocket.auth.ReadOnly;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/profile")
@ -152,15 +151,18 @@ public class ProfileController {
@PUT
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Response setProfile(@Mutable @Auth AuthenticatedDevice auth, @NotNull @Valid CreateProfileRequest request) {
public Response setProfile(@Auth AuthenticatedDevice auth, @NotNull @Valid CreateProfileRequest request) {
final Optional<VersionedProfile> currentProfile = profilesManager.get(auth.getAccount().getUuid(),
request.version());
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final Optional<VersionedProfile> currentProfile =
profilesManager.get(auth.accountIdentifier(), request.version());
if (request.paymentAddress() != null && request.paymentAddress().length != 0) {
final boolean hasDisallowedPrefix =
dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()
.anyMatch(prefix -> auth.getAccount().getNumber().startsWith(prefix));
.anyMatch(prefix -> account.getNumber().startsWith(prefix));
if (hasDisallowedPrefix && currentProfile.map(VersionedProfile::paymentAddress).isEmpty()) {
return Response.status(Response.Status.FORBIDDEN).build();
@ -179,7 +181,7 @@ public class ProfileController {
case UPDATE -> ProfileHelper.generateAvatarObjectName();
};
profilesManager.set(auth.getAccount().getUuid(),
profilesManager.set(auth.accountIdentifier(),
new VersionedProfile(
request.version(),
request.name(),
@ -194,7 +196,7 @@ public class ProfileController {
currentAvatar.ifPresent(s -> profilesManager.deleteAvatar(s).join());
}
accountsManager.update(auth.getAccount(), a -> {
accountsManager.update(account, a -> {
final List<AccountBadge> updatedBadges = request.badges()
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, a.getBadges()))
@ -216,7 +218,7 @@ public class ProfileController {
@Path("/{identifier}/{version}")
@ManagedAsync
public VersionedProfileResponse getProfile(
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
@Context ContainerRequestContext containerRequestContext,
@PathParam("identifier") AciServiceIdentifier accountIdentifier,
@ -224,7 +226,11 @@ public class ProfileController {
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
throws RateLimitExceededException {
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
final Optional<Account> maybeRequester =
maybeAuthenticatedDevice.map(
authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier, "getVersionedProfile", userAgent);
return buildVersionedProfileResponse(targetAccount,
@ -238,7 +244,7 @@ public class ProfileController {
@Produces(MediaType.APPLICATION_JSON)
@Path("/{identifier}/{version}/{credentialRequest}")
public CredentialProfileResponse getProfile(
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
@Context ContainerRequestContext containerRequestContext,
@PathParam("identifier") AciServiceIdentifier accountIdentifier,
@ -252,7 +258,11 @@ public class ProfileController {
throw new BadRequestException();
}
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
final Optional<Account> maybeRequester =
maybeAuthenticatedDevice.map(
authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier, "credentialRequest", userAgent);
final boolean isSelf = maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false);
@ -270,7 +280,7 @@ public class ProfileController {
@Path("/{identifier}")
@ManagedAsync
public BaseProfileResponse getUnversionedProfile(
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
@HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,
@Context ContainerRequestContext containerRequestContext,
@ -278,7 +288,10 @@ public class ProfileController {
@PathParam("identifier") ServiceIdentifier identifier)
throws RateLimitExceededException {
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
final Optional<Account> maybeRequester =
maybeAuthenticatedDevice.map(
authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
final Account targetAccount;
if (groupSendToken.isPresent()) {

View File

@ -34,7 +34,6 @@ import org.whispersystems.textsecuregcm.entities.ProvisioningMessage;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.ProvisioningManager;
import org.whispersystems.websocket.auth.ReadOnly;
/**
* The provisioning controller facilitates transmission of provisioning messages from the primary device associated with
@ -77,7 +76,7 @@ public class ProvisioningController {
@ApiResponse(responseCode="204", description="The provisioning message was delivered to the given provisioning address")
@ApiResponse(responseCode="400", description="The provisioning message was too large")
@ApiResponse(responseCode="404", description="No device with the given provisioning address was connected at the time of the request")
public void sendProvisioningMessage(@ReadOnly @Auth final AuthenticatedDevice auth,
public void sendProvisioningMessage(@Auth final AuthenticatedDevice auth,
@Parameter(description = "The temporary provisioning address to which to send a provisioning message")
@PathParam("destination") final String provisioningAddress,
@ -93,7 +92,7 @@ public class ProvisioningController {
throw new WebApplicationException(Response.Status.BAD_REQUEST);
}
rateLimiters.getMessagesLimiter().validate(auth.getAccount().getUuid());
rateLimiters.getMessagesLimiter().validate(auth.accountIdentifier());
final boolean subscriberPresent =
provisioningManager.sendProvisioningMessage(provisioningAddress, Base64.getMimeDecoder().decode(message.body()));

View File

@ -30,7 +30,6 @@ import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
import org.whispersystems.textsecuregcm.util.Conversions;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v1/config")
@Tag(name = "Remote Config")
@ -64,7 +63,7 @@ public class RemoteConfigController {
"""
)
@ApiResponse(responseCode = "200", description = "Remote configuration values for the authenticated user", useReturnTypeSchema = true)
public UserRemoteConfigList getAll(@ReadOnly @Auth AuthenticatedDevice auth) {
public UserRemoteConfigList getAll(@Auth AuthenticatedDevice auth) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA1");
@ -73,7 +72,7 @@ public class RemoteConfigController {
return new UserRemoteConfigList(Stream.concat(remoteConfigsManager.getAll().stream().map(config -> {
final byte[] hashKey = config.getHashKey() != null ? config.getHashKey().getBytes(StandardCharsets.UTF_8)
: config.getName().getBytes(StandardCharsets.UTF_8);
boolean inBucket = isInBucket(digest, auth.getAccount().getUuid(), hashKey, config.getPercentage(),
boolean inBucket = isInBucket(digest, auth.accountIdentifier(), hashKey, config.getPercentage(),
config.getUuids());
return new UserRemoteConfig(config.getName(), inBucket,
inBucket ? config.getValue() : config.getDefaultValue());

View File

@ -17,7 +17,6 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v1/storage")
@Tag(name = "Secure Storage")
@ -47,7 +46,7 @@ public class SecureStorageController {
"""
)
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
public ExternalServiceCredentials getAuth(@ReadOnly @Auth AuthenticatedDevice auth) {
return storageServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid());
public ExternalServiceCredentials getAuth(@Auth AuthenticatedDevice auth) {
return storageServiceCredentialsGenerator.generateForUuid(auth.accountIdentifier());
}
}

View File

@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers;
import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.auth.Auth;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@ -27,27 +28,27 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v2/backup")
@Path("/v2/{name: backup|svr}")
@Tag(name = "Secure Value Recovery")
@Schema(description = "Note: /v2/backup is deprecated. Use /v2/svr instead.")
public class SecureValueRecovery2Controller {
private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg) {
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecoveryConfiguration cfg) {
return credentialsGenerator(cfg, Clock.systemUTC());
}
@VisibleForTesting
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg, final Clock clock) {
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecoveryConfiguration cfg, final Clock clock) {
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
.withUserDerivationKey(cfg.userIdTokenSharedSecret().value())
@ -78,8 +79,8 @@ public class SecureValueRecovery2Controller {
)
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
public ExternalServiceCredentials getAuth(@ReadOnly @Auth final AuthenticatedDevice auth) {
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
public ExternalServiceCredentials getAuth(@Auth final AuthenticatedDevice auth) {
return backupServiceCredentialGenerator.generateFor(auth.accountIdentifier().toString());
}

View File

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

View File

@ -28,7 +28,6 @@ import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.websocket.auth.ReadOnly;
@Path("/v1/sticker")
@Tag(name = "Stickers")
@ -47,10 +46,10 @@ public class StickerController {
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/pack/form/{count}")
public StickerPackFormUploadAttributes getStickersForm(@ReadOnly @Auth AuthenticatedDevice auth,
public StickerPackFormUploadAttributes getStickersForm(@Auth AuthenticatedDevice auth,
@PathParam("count") @Min(1) @Max(201) int stickerCount)
throws RateLimitExceededException {
rateLimiters.getStickerPackLimiter().validate(auth.getAccount().getUuid());
rateLimiters.getStickerPackLimiter().validate(auth.accountIdentifier());
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
String packId = generatePackId();

View File

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

View File

@ -5,9 +5,9 @@
package org.whispersystems.textsecuregcm.controllers;
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
import javax.annotation.Nullable;
import java.time.Duration;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
public class VerificationSessionRateLimitExceededException extends RateLimitExceededException {

View File

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

View File

@ -10,15 +10,14 @@ import com.webauthn4j.converter.jackson.deserializer.json.ByteArrayBase64Deseria
import io.micrometer.core.instrument.Metrics;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
import javax.annotation.Nullable;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.Arrays;
import java.util.Objects;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.Account;
import java.util.Arrays;
import java.util.Objects;
public record IncomingMessage(int type,
byte destinationDeviceId,
@ -35,7 +34,7 @@ public record IncomingMessage(int type,
MetricsUtil.name(IncomingMessage.class, "rejectInvalidEnvelopeType");
public MessageProtos.Envelope toEnvelope(final ServiceIdentifier destinationIdentifier,
@Nullable Account sourceAccount,
@Nullable AciServiceIdentifier sourceServiceIdentifier,
@Nullable Byte sourceDeviceId,
final long timestamp,
final boolean story,
@ -54,9 +53,9 @@ public record IncomingMessage(int type,
.setEphemeral(ephemeral)
.setUrgent(urgent);
if (sourceAccount != null && sourceDeviceId != null) {
if (sourceServiceIdentifier != null && sourceDeviceId != null) {
envelopeBuilder
.setSourceServiceId(new AciServiceIdentifier(sourceAccount.getUuid()).toServiceIdentifierString())
.setSourceServiceId(sourceServiceIdentifier.toServiceIdentifierString())
.setSourceDevice(sourceDeviceId.intValue());
}

View File

@ -96,8 +96,8 @@ public class RestDeprecationFilter implements ContainerRequestFilter {
return false;
}
if (securityContext.getUserPrincipal() instanceof AuthenticatedDevice ad) {
return experimentEnrollmentManager.isEnrolled(ad.getAccount().getUuid(), AUTHENTICATED_EXPERIMENT_NAME);
if (securityContext.getUserPrincipal() instanceof AuthenticatedDevice authenticatedDevice) {
return experimentEnrollmentManager.isEnrolled(authenticatedDevice.accountIdentifier(), AUTHENTICATED_EXPERIMENT_NAME);
} else {
log.error("Security context was not null but user principal was of type {}", securityContext.getUserPrincipal().getClass().getName());
return false;

View File

@ -32,6 +32,7 @@ import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import reactor.core.publisher.Mono;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
@ -60,9 +61,15 @@ public class BackupsGrpcService extends ReactorBackupsGrpc.BackupsImplBase {
BackupAuthCredentialRequest::new,
request.getMediaBackupAuthCredentialRequest().toByteArray());
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
return authenticatedAccount()
.flatMap(account -> Mono.fromFuture(
backupAuthManager.commitBackupId(account, messagesCredentialRequest, mediaCredentialRequest)))
.flatMap(account -> {
final Device device = account
.getDevice(authenticatedDevice.deviceId())
.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException);
return Mono.fromFuture(
backupAuthManager.commitBackupId(account, device, messagesCredentialRequest, mediaCredentialRequest));
})
.thenReturn(SetBackupIdResponse.getDefaultInstance());
}

View File

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

View File

@ -0,0 +1,140 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import com.google.common.annotations.VisibleForTesting;
import io.grpc.Status;
import org.signal.keytransparency.client.AciMonitorRequest;
import org.signal.keytransparency.client.ConsistencyParameters;
import org.signal.keytransparency.client.DistinguishedRequest;
import org.signal.keytransparency.client.DistinguishedResponse;
import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.SimpleKeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
public class KeyTransparencyGrpcService extends
SimpleKeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceImplBase {
@VisibleForTesting
static final int COMMITMENT_INDEX_LENGTH = 32;
private final RateLimiters rateLimiters;
private final KeyTransparencyServiceClient client;
public KeyTransparencyGrpcService(final RateLimiters rateLimiters,
final KeyTransparencyServiceClient client) {
this.rateLimiters = rateLimiters;
this.client = client;
}
@Override
public SearchResponse search(final SearchRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencySearchLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
return client.search(validateSearchRequest(request));
}
@Override
public MonitorResponse monitor(final MonitorRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencyMonitorLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
return client.monitor(validateMonitorRequest(request));
}
@Override
public DistinguishedResponse distinguished(final DistinguishedRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencyDistinguishedLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
// A client's very first distinguished request will not have a "last" parameter
if (request.hasLast() && request.getLast() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
}
return client.distinguished(request);
}
private SearchRequest validateSearchRequest(final SearchRequest request) {
if (request.hasE164SearchRequest()) {
final E164SearchRequest e164SearchRequest = request.getE164SearchRequest();
if (e164SearchRequest.getUnidentifiedAccessKey().isEmpty() != e164SearchRequest.getE164().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Unidentified access key and E164 must be provided together or not at all").asRuntimeException();
}
}
if (!request.getConsistency().hasDistinguished()) {
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished tree head size").asRuntimeException();
}
validateConsistencyParameters(request.getConsistency());
return request;
}
private MonitorRequest validateMonitorRequest(final MonitorRequest request) {
final AciMonitorRequest aciMonitorRequest = request.getAci();
try {
AciServiceIdentifier.fromBytes(aciMonitorRequest.getAci().toByteArray());
} catch (IllegalArgumentException e) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid ACI").asRuntimeException();
}
if (aciMonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Aci entry position must be positive").asRuntimeException();
}
if (aciMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Aci commitment index must be 32 bytes").asRuntimeException();
}
if (request.hasUsernameHash()) {
final UsernameHashMonitorRequest usernameHashMonitorRequest = request.getUsernameHash();
if (usernameHashMonitorRequest.getUsernameHash().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash cannot be empty").asRuntimeException();
}
if (usernameHashMonitorRequest.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid username hash length").asRuntimeException();
}
if (usernameHashMonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash entry position must be positive").asRuntimeException();
}
if (usernameHashMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash commitment index must be 32 bytes").asRuntimeException();
}
}
if (request.hasE164()) {
final E164MonitorRequest e164MonitorRequest = request.getE164();
if (e164MonitorRequest.getE164().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("E164 cannot be empty").asRuntimeException();
}
if (e164MonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("E164 entry position must be positive").asRuntimeException();
}
if (e164MonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("E164 commitment index must be 32 bytes").asRuntimeException();
}
}
if (!request.getConsistency().hasDistinguished() || !request.getConsistency().hasLast()) {
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished and last tree head sizes").asRuntimeException();
}
validateConsistencyParameters(request.getConsistency());
return request;
}
private static void validateConsistencyParameters(final ConsistencyParameters consistency) {
if (consistency.getDistinguished() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Distinguished tree head size must be positive").asRuntimeException();
}
if (consistency.hasLast() && consistency.getLast() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
}
}
}

View File

@ -8,7 +8,6 @@ package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import java.util.UUID;
import org.signal.chat.common.IdentityType;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
@ -40,4 +39,12 @@ public class ServiceIdentifierUtil {
.setUuid(UUIDUtil.toByteString(serviceIdentifier.uuid()))
.build();
}
public static ByteString toCompactByteString(final ServiceIdentifier serviceIdentifier) {
return ByteString.copyFrom(serviceIdentifier.toCompactByteArray());
}
public static ServiceIdentifier fromByteString(final ByteString byteString) {
return ServiceIdentifier.fromBytes(byteString.toByteArray());
}
}

View File

@ -1,6 +1,5 @@
package org.whispersystems.textsecuregcm.keytransparency;
import com.google.protobuf.AbstractMessageLite;
import com.google.protobuf.ByteString;
import io.dropwizard.lifecycle.Managed;
import io.grpc.ChannelCredentials;
@ -20,44 +19,43 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import org.signal.keytransparency.client.AciMonitorRequest;
import org.signal.keytransparency.client.ConsistencyParameters;
import org.signal.keytransparency.client.DistinguishedRequest;
import org.signal.keytransparency.client.DistinguishedResponse;
import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.util.CompletableFutureUtil;
public class KeyTransparencyServiceClient implements Managed {
private static final String DAYS_UNTIL_CLIENT_CERTIFICATE_EXPIRATION_GAUGE_NAME =
MetricsUtil.name(KeyTransparencyServiceClient.class, "daysUntilClientCertificateExpiration");
private static final Duration KEY_TRANSPARENCY_RPC_TIMEOUT = Duration.ofSeconds(15);
private static final Logger logger = LoggerFactory.getLogger(KeyTransparencyServiceClient.class);
private final Executor callbackExecutor;
private final String host;
private final int port;
private final ChannelCredentials tlsChannelCredentials;
private ManagedChannel channel;
private KeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceFutureStub stub;
private KeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceBlockingStub stub;
public KeyTransparencyServiceClient(
final String host,
final int port,
final String tlsCertificate,
final String clientCertificate,
final String clientPrivateKey,
final Executor callbackExecutor
final String clientPrivateKey
) throws IOException {
this.host = host;
this.port = port;
@ -76,7 +74,6 @@ public class KeyTransparencyServiceClient implements Managed {
configureClientCertificateMetrics(clientCertificate);
}
this.callbackExecutor = callbackExecutor;
}
private void configureClientCertificateMetrics(String clientCertificate) {
@ -113,14 +110,13 @@ public class KeyTransparencyServiceClient implements Managed {
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public CompletableFuture<byte[]> search(
public SearchResponse search(
final ByteString aci,
final ByteString aciIdentityKey,
final Optional<ByteString> usernameHash,
final Optional<E164SearchRequest> e164SearchRequest,
final Optional<Long> lastTreeHeadSize,
final long distinguishedTreeHeadSize,
final Duration timeout) {
final long distinguishedTreeHeadSize) {
final SearchRequest.Builder searchRequestBuilder = SearchRequest.newBuilder()
.setAci(aci)
.setAciIdentityKey(aciIdentityKey);
@ -133,19 +129,20 @@ public class KeyTransparencyServiceClient implements Managed {
lastTreeHeadSize.ifPresent(consistency::setLast);
searchRequestBuilder.setConsistency(consistency.build());
return search(searchRequestBuilder.build());
}
return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout))
.search(searchRequestBuilder.build()), callbackExecutor)
.thenApply(AbstractMessageLite::toByteArray);
public SearchResponse search(final SearchRequest request) {
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
.search(request);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public CompletableFuture<byte[]> monitor(final AciMonitorRequest aciMonitorRequest,
public MonitorResponse monitor(final AciMonitorRequest aciMonitorRequest,
final Optional<UsernameHashMonitorRequest> usernameHashMonitorRequest,
final Optional<E164MonitorRequest> e164MonitorRequest,
final long lastTreeHeadSize,
final long distinguishedTreeHeadSize,
final Duration timeout) {
final long distinguishedTreeHeadSize) {
final MonitorRequest.Builder monitorRequestBuilder = MonitorRequest.newBuilder()
.setAci(aciMonitorRequest)
.setConsistency(ConsistencyParameters.newBuilder()
@ -155,20 +152,26 @@ public class KeyTransparencyServiceClient implements Managed {
usernameHashMonitorRequest.ifPresent(monitorRequestBuilder::setUsernameHash);
e164MonitorRequest.ifPresent(monitorRequestBuilder::setE164);
return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout))
.monitor(monitorRequestBuilder.build()), callbackExecutor)
.thenApply(AbstractMessageLite::toByteArray);
return monitor(monitorRequestBuilder.build());
}
public MonitorResponse monitor(final MonitorRequest request) {
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
.monitor(request);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public CompletableFuture<byte[]> getDistinguishedKey(final Optional<Long> lastTreeHeadSize, final Duration timeout) {
public DistinguishedResponse getDistinguishedKey(final Optional<Long> lastTreeHeadSize) {
final DistinguishedRequest request = lastTreeHeadSize.map(
last -> DistinguishedRequest.newBuilder().setLast(last).build())
.orElseGet(DistinguishedRequest::getDefaultInstance);
return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout)).distinguished(request),
callbackExecutor)
.thenApply(AbstractMessageLite::toByteArray);
return distinguished(request);
}
public DistinguishedResponse distinguished(final DistinguishedRequest request) {
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
.distinguished(request);
}
private static Deadline toDeadline(final Duration timeout) {
@ -180,7 +183,7 @@ public class KeyTransparencyServiceClient implements Managed {
channel = Grpc.newChannelBuilderForAddress(host, port, tlsChannelCredentials)
.idleTimeout(1, TimeUnit.MINUTES)
.build();
stub = KeyTransparencyQueryServiceGrpc.newFutureStub(channel);
stub = KeyTransparencyQueryServiceGrpc.newBlockingStub(channel);
}
@Override

View File

@ -206,4 +206,16 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
public RateLimiter getWaitForTransferArchiveLimiter() {
return forDescriptor(For.WAIT_FOR_TRANSFER_ARCHIVE);
}
public RateLimiter getKeyTransparencySearchLimiter() {
return forDescriptor(For.KEY_TRANSPARENCY_SEARCH_PER_IP);
}
public RateLimiter getKeyTransparencyDistinguishedLimiter() {
return forDescriptor(For.KEY_TRANSPARENCY_DISTINGUISHED_PER_IP);
}
public RateLimiter getKeyTransparencyMonitorLimiter() {
return forDescriptor(For.KEY_TRANSPARENCY_MONITOR_PER_IP);
}
}

View File

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

View File

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

View File

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

View File

@ -9,8 +9,6 @@ import java.util.Optional;
import jakarta.ws.rs.core.Response;
import org.signal.chat.messages.SendMessageResponse;
import org.signal.chat.messages.SendMultiRecipientMessageResponse;
import org.whispersystems.textsecuregcm.auth.AccountAndAuthenticatedDeviceHolder;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account;
@ -31,7 +29,7 @@ public interface SpamChecker {
SpamCheckResult<Response> checkForIndividualRecipientSpamHttp(
final MessageType messageType,
final ContainerRequestContext requestContext,
final Optional<? extends AccountAndAuthenticatedDeviceHolder> maybeSource,
final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> maybeSource,
final Optional<Account> maybeDestination,
final ServiceIdentifier destinationIdentifier);
@ -79,7 +77,7 @@ public interface SpamChecker {
@Override
public SpamCheckResult<Response> checkForIndividualRecipientSpamHttp(final MessageType messageType,
final ContainerRequestContext requestContext,
final Optional<? extends AccountAndAuthenticatedDeviceHolder> maybeSource,
final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> maybeSource,
final Optional<Account> maybeDestination,
final ServiceIdentifier destinationIdentifier) {
@ -95,7 +93,7 @@ public interface SpamChecker {
@Override
public SpamCheckResult<GrpcResponse<SendMessageResponse>> checkForIndividualRecipientSpamGrpc(final MessageType messageType,
final Optional<AuthenticatedDevice> maybeSource,
final Optional<org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice> maybeSource,
final Optional<Account> maybeDestination,
final ServiceIdentifier destinationIdentifier) {

View File

@ -1,36 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.websocket.auth.PrincipalSupplier;
public class AccountPrincipalSupplier implements PrincipalSupplier<AuthenticatedDevice> {
private final AccountsManager accountsManager;
public AccountPrincipalSupplier(final AccountsManager accountsManager) {
this.accountsManager = accountsManager;
}
@Override
public AuthenticatedDevice refresh(final AuthenticatedDevice oldAccount) {
final Account account = accountsManager.getByAccountIdentifier(oldAccount.getAccount().getUuid())
.orElseThrow(() -> new RefreshingAccountNotFoundException("Could not find account"));
final Device device = account.getDevice(oldAccount.getAuthenticatedDevice().getId())
.orElseThrow(() -> new RefreshingAccountNotFoundException("Could not find device"));
return new AuthenticatedDevice(account, device);
}
@Override
public AuthenticatedDevice deepCopy(final AuthenticatedDevice authenticatedDevice) {
final Account cloned = AccountUtil.cloneAccountAsNotStale(authenticatedDevice.getAccount());
return new AuthenticatedDevice(
cloned,
cloned.getDevice(authenticatedDevice.getAuthenticatedDevice().getId())
.orElseThrow(() -> new IllegalStateException(
"Could not find device from a clone of an account where the device was present")));
}
}

View File

@ -600,13 +600,10 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
}
/**
* Unlink a device from the given account. The device will be immediately disconnected if it is
* connected to any chat frontend, but it is the caller's responsibility to make sure that the
* account's *other* devices are disconnected, either by use of
* {@link org.whispersystems.textsecuregcm.auth.LinkedDeviceRefreshRequirementProvider} or
* directly by calling {@link DeviceDisconnectionManager#requestDisconnection}.
* Unlink a device from the given account. The device will be immediately disconnected if it is connected to any chat
* frontend.
*
* @returns the updated Account
* @return the updated Account
*/
public CompletableFuture<Account> removeDevice(final Account account, final byte deviceId) {
if (deviceId == Device.PRIMARY_ID) {

View File

@ -0,0 +1,24 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* The prekey pages stored for a particular device
*
* @param identifier The account identifier or phone number identifier that the keys belong to
* @param deviceId The device identifier
* @param currentPage If present, the active stored page prekeys are being distributed from
* @param pageIdToLastModified The last modified time for all the device's stored pages, keyed by the pageId
*/
public record DeviceKEMPreKeyPages(
UUID identifier, byte deviceId,
Optional<UUID> currentPage,
Map<UUID, Instant> pageIdToLastModified) {}

View File

@ -0,0 +1,106 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import java.util.UUID;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
import org.whispersystems.textsecuregcm.grpc.ServiceIdentifierUtil;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
/**
* Provides utility methods for "compressing" and "expanding" envelopes. Historically UUID-like fields in envelopes have
* been represented as strings (e.g. "c15f1dfb-ae2c-43a8-9bb9-baba97ac416c"), but <em>could</em> be represented as more
* compact byte arrays instead. Existing clients generally expect string representations (though that should change in
* the near future), but we can use the more compressed forms at rest for more efficient storage and transfer.
*/
public class EnvelopeUtil {
/**
* Converts all "compressible" UUID-like fields in the given envelope to more compact binary representations.
*
* @param envelope the envelope to compress
*
* @return an envelope with string-based UUID-like fields compressed to binary representations
*/
public static MessageProtos.Envelope compress(final MessageProtos.Envelope envelope) {
final MessageProtos.Envelope.Builder builder = envelope.toBuilder();
if (builder.hasSourceServiceId()) {
final ServiceIdentifier sourceServiceId = ServiceIdentifier.valueOf(builder.getSourceServiceId());
builder.setSourceServiceIdBinary(ServiceIdentifierUtil.toCompactByteString(sourceServiceId));
builder.clearSourceServiceId();
}
if (builder.hasDestinationServiceId()) {
final ServiceIdentifier destinationServiceId = ServiceIdentifier.valueOf(builder.getDestinationServiceId());
builder.setDestinationServiceIdBinary(ServiceIdentifierUtil.toCompactByteString(destinationServiceId));
builder.clearDestinationServiceId();
}
if (builder.hasServerGuid()) {
final UUID serverGuid = UUID.fromString(builder.getServerGuid());
builder.setServerGuidBinary(UUIDUtil.toByteString(serverGuid));
builder.clearServerGuid();
}
if (builder.hasUpdatedPni()) {
final UUID updatedPni = UUID.fromString(builder.getUpdatedPni());
builder.setUpdatedPniBinary(UUIDUtil.toByteString(updatedPni));
builder.clearUpdatedPni();
}
return builder.build();
}
/**
* "Expands" all binary representations of UUID-like fields to string representations to meet current client
* expectations.
*
* @param envelope the envelope to expand
*
* @return an envelope with binary representations of UUID-like fields expanded to string representations
*/
public static MessageProtos.Envelope expand(final MessageProtos.Envelope envelope) {
final MessageProtos.Envelope.Builder builder = envelope.toBuilder();
if (builder.hasSourceServiceIdBinary()) {
final ServiceIdentifier sourceServiceId =
ServiceIdentifierUtil.fromByteString(builder.getSourceServiceIdBinary());
builder.setSourceServiceId(sourceServiceId.toServiceIdentifierString());
builder.clearSourceServiceIdBinary();
}
if (builder.hasDestinationServiceIdBinary()) {
final ServiceIdentifier destinationServiceId =
ServiceIdentifierUtil.fromByteString(builder.getDestinationServiceIdBinary());
builder.setDestinationServiceId(destinationServiceId.toServiceIdentifierString());
builder.clearDestinationServiceIdBinary();
}
if (builder.hasServerGuidBinary()) {
final UUID serverGuid = UUIDUtil.fromByteString(builder.getServerGuidBinary());
builder.setServerGuid(serverGuid.toString());
builder.clearServerGuidBinary();
}
if (builder.hasUpdatedPniBinary()) {
final UUID updatedPni = UUIDUtil.fromByteString(builder.getUpdatedPniBinary());
// Note that expanded envelopes include BOTH forms of the `updatedPni` field
builder.setUpdatedPni(updatedPni.toString());
}
return builder.build();
}
}

View File

@ -0,0 +1,136 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import com.google.common.annotations.VisibleForTesting;
import java.nio.ByteBuffer;
import java.util.List;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.kem.KEMPublicKey;
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
class KEMPreKeyPage {
static final byte FORMAT = 1;
// Serialized pages start with a 4 byte magic constant, followed by 3 bytes of 0s and then the format byte
static final int HEADER_MAGIC = 0xC21C6DB8;
static final int HEADER_SIZE = 8;
// Serialize bigendian to produce the serialized page header
private static final long HEADER = ((long) HEADER_MAGIC) << 32L | (long) FORMAT;
// The length of libsignal's serialized KEM public key, which is a single-byte version followed by the public key
private static final int SERIALIZED_PUBKEY_LENGTH = 1569;
private static final int SERIALIZED_SIGNATURE_LENGTH = 64;
private static final int KEY_ID_LENGTH = Long.BYTES;
// The internal prefix byte libsignal uses to indicate a key is of type KEMKeyType.KYBER_1024. Currently, this
// is the only type of key allowed to be written to a prekey page
private static final byte KEM_KEY_TYPE_KYBER_1024 = 0x08;
@VisibleForTesting
static final int SERIALIZED_PREKEY_LENGTH = KEY_ID_LENGTH + SERIALIZED_PUBKEY_LENGTH + SERIALIZED_SIGNATURE_LENGTH;
private KEMPreKeyPage() {}
/**
* Serialize the list of preKeys into a single buffer
*
* @param format the format to serialize as. Currently, the only valid format is {@link KEMPreKeyPage#FORMAT}
* @param preKeys the preKeys to serialize
* @return The serialized buffer and a format to store alongside the buffer
*/
static ByteBuffer serialize(final byte format, final List<KEMSignedPreKey> preKeys) {
if (format != FORMAT) {
throw new IllegalArgumentException("Unknown format: " + format + ", must be " + FORMAT);
}
if (preKeys.isEmpty()) {
throw new IllegalArgumentException("PreKeys cannot be empty");
}
final ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE + SERIALIZED_PREKEY_LENGTH * preKeys.size());
buffer.putLong(HEADER);
for (KEMSignedPreKey preKey : preKeys) {
buffer.putLong(preKey.keyId());
final byte[] publicKeyBytes = preKey.serializedPublicKey();
if (publicKeyBytes[0] != KEM_KEY_TYPE_KYBER_1024) {
// 0x08 is libsignal's current KEM key format. If some future version of libsignal supports additional KEM
// keys, we'll have to roll out read support before rolling out write support. Otherwise, we may write keys
// to storage that are not readable by other chat instances.
throw new IllegalArgumentException("Format 1 only supports " + KEM_KEY_TYPE_KYBER_1024 + " public keys");
}
if (publicKeyBytes.length != SERIALIZED_PUBKEY_LENGTH) {
throw new IllegalArgumentException("Unexpected public key length " + publicKeyBytes.length);
}
buffer.put(publicKeyBytes);
if (preKey.signature().length != SERIALIZED_SIGNATURE_LENGTH) {
throw new IllegalArgumentException("prekey signature length must be " + SERIALIZED_SIGNATURE_LENGTH);
}
buffer.put(preKey.signature());
}
buffer.flip();
return buffer;
}
/**
* Deserialize a single {@link KEMSignedPreKey}
*
* @param format The format of the page this buffer is from
* @param buffer The key to deserialize. The position of the buffer should be the start of the key, and the limit of
* the buffer should be the end of the key. After a successful deserialization the position of the
* buffer will be the limit
* @return The deserialized key
* @throws InvalidKeyException
*/
static KEMSignedPreKey deserializeKey(int format, ByteBuffer buffer) throws InvalidKeyException {
if (format != FORMAT) {
throw new IllegalArgumentException("Unknown prekey page format " + format);
}
if (buffer.remaining() != SERIALIZED_PREKEY_LENGTH) {
throw new IllegalArgumentException("PreKeys must be length " + SERIALIZED_PREKEY_LENGTH);
}
final long keyId = buffer.getLong();
final byte[] publicKeyBytes = new byte[SERIALIZED_PUBKEY_LENGTH];
buffer.get(publicKeyBytes);
final KEMPublicKey kemPublicKey = new KEMPublicKey(publicKeyBytes);
final byte[] signature = new byte[SERIALIZED_SIGNATURE_LENGTH];
buffer.get(signature);
return new KEMSignedPreKey(keyId, kemPublicKey, signature);
}
/**
* The location of a specific key within a serialized page
*/
record KeyLocation(int start, int length) {
int getStartInclusive() {
return start;
}
int getEndInclusive() {
return start + length - 1;
}
}
/**
* Get the location of the key at the provided index within a page
*
* @param format The format of the page
* @param index The index of the key to retrieve
* @return An {@link KeyLocation} indicating where within the page the key is
*/
static KeyLocation keyLocation(final int format, final int index) {
if (format != FORMAT) {
throw new IllegalArgumentException("unknown format " + format);
}
final int startOffset = HEADER_SIZE + (index * SERIALIZED_PREKEY_LENGTH);
return new KeyLocation(startOffset, SERIALIZED_PREKEY_LENGTH);
}
}

View File

@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.storage;
import io.micrometer.core.instrument.Metrics;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ -12,26 +13,37 @@ import java.util.concurrent.CompletableFuture;
import org.whispersystems.textsecuregcm.entities.ECPreKey;
import org.whispersystems.textsecuregcm.entities.ECSignedPreKey;
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import reactor.core.publisher.Flux;
import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;
public class KeysManager {
private final SingleUseECPreKeyStore ecPreKeys;
private final SingleUseKEMPreKeyStore pqPreKeys;
private final PagedSingleUseKEMPreKeyStore pagedPqPreKeys;
private final RepeatedUseECSignedPreKeyStore ecSignedPreKeys;
private final RepeatedUseKEMSignedPreKeyStore pqLastResortKeys;
private final ExperimentEnrollmentManager experimentEnrollmentManager;
public static String PAGED_KEYS_EXPERIMENT_NAME = "pagedPreKeys";
private static final String TAKE_PQ_NAME = MetricsUtil.name(KeysManager.class, "takePq");
public KeysManager(
final DynamoDbAsyncClient dynamoDbAsyncClient,
final String ecTableName,
final String pqTableName,
final String ecSignedPreKeysTableName,
final String pqLastResortTableName) {
this.ecPreKeys = new SingleUseECPreKeyStore(dynamoDbAsyncClient, ecTableName);
this.pqPreKeys = new SingleUseKEMPreKeyStore(dynamoDbAsyncClient, pqTableName);
this.ecSignedPreKeys = new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient, ecSignedPreKeysTableName);
this.pqLastResortKeys = new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient, pqLastResortTableName);
final SingleUseECPreKeyStore ecPreKeys,
final SingleUseKEMPreKeyStore pqPreKeys,
final PagedSingleUseKEMPreKeyStore pagedPqPreKeys,
final RepeatedUseECSignedPreKeyStore ecSignedPreKeys,
final RepeatedUseKEMSignedPreKeyStore pqLastResortKeys,
final ExperimentEnrollmentManager experimentEnrollmentManager) {
this.ecPreKeys = ecPreKeys;
this.pqPreKeys = pqPreKeys;
this.pagedPqPreKeys = pagedPqPreKeys;
this.ecSignedPreKeys = ecSignedPreKeys;
this.pqLastResortKeys = pqLastResortKeys;
this.experimentEnrollmentManager = experimentEnrollmentManager;
}
public TransactWriteItem buildWriteItemForEcSignedPreKey(final UUID identifier,
@ -76,22 +88,31 @@ public class KeysManager {
);
}
public CompletableFuture<Void> storeEcSignedPreKeys(final UUID identifier, final byte deviceId, final ECSignedPreKey ecSignedPreKey) {
public CompletableFuture<Void> storeEcSignedPreKeys(final UUID identifier, final byte deviceId,
final ECSignedPreKey ecSignedPreKey) {
return ecSignedPreKeys.store(identifier, deviceId, ecSignedPreKey);
}
public CompletableFuture<Void> storePqLastResort(final UUID identifier, final byte deviceId, final KEMSignedPreKey lastResortKey) {
public CompletableFuture<Void> storePqLastResort(final UUID identifier, final byte deviceId,
final KEMSignedPreKey lastResortKey) {
return pqLastResortKeys.store(identifier, deviceId, lastResortKey);
}
public CompletableFuture<Void> storeEcOneTimePreKeys(final UUID identifier, final byte deviceId,
final List<ECPreKey> preKeys) {
final List<ECPreKey> preKeys) {
return ecPreKeys.store(identifier, deviceId, preKeys);
}
public CompletableFuture<Void> storeKemOneTimePreKeys(final UUID identifier, final byte deviceId,
final List<KEMSignedPreKey> preKeys) {
return pqPreKeys.store(identifier, deviceId, preKeys);
final List<KEMSignedPreKey> preKeys) {
final boolean enrolledInPagedKeys = experimentEnrollmentManager.isEnrolled(identifier, PAGED_KEYS_EXPERIMENT_NAME);
final CompletableFuture<Void> deleteOtherKeys = enrolledInPagedKeys
? pqPreKeys.delete(identifier, deviceId)
: pagedPqPreKeys.delete(identifier, deviceId);
return deleteOtherKeys.thenCompose(ignored -> enrolledInPagedKeys
? pagedPqPreKeys.store(identifier, deviceId, preKeys)
: pqPreKeys.store(identifier, deviceId, preKeys));
}
public CompletableFuture<Optional<ECPreKey>> takeEC(final UUID identifier, final byte deviceId) {
@ -99,10 +120,36 @@ public class KeysManager {
}
public CompletableFuture<Optional<KEMSignedPreKey>> takePQ(final UUID identifier, final byte deviceId) {
return pqPreKeys.take(identifier, deviceId)
final boolean enrolledInPagedKeys = experimentEnrollmentManager.isEnrolled(identifier, PAGED_KEYS_EXPERIMENT_NAME);
return tagTakePQ(pagedPqPreKeys.take(identifier, deviceId), PQSource.PAGE, enrolledInPagedKeys)
.thenCompose(maybeSingleUsePreKey -> maybeSingleUsePreKey
.map(ignored -> CompletableFuture.completedFuture(maybeSingleUsePreKey))
.orElseGet(() -> tagTakePQ(pqPreKeys.take(identifier, deviceId), PQSource.ROW, enrolledInPagedKeys)))
.thenCompose(maybeSingleUsePreKey -> maybeSingleUsePreKey
.map(singleUsePreKey -> CompletableFuture.completedFuture(maybeSingleUsePreKey))
.orElseGet(() -> pqLastResortKeys.find(identifier, deviceId)));
.orElseGet(() -> tagTakePQ(pqLastResortKeys.find(identifier, deviceId), PQSource.LAST_RESORT, enrolledInPagedKeys)));
}
private enum PQSource {
PAGE,
ROW,
LAST_RESORT
}
private CompletableFuture<Optional<KEMSignedPreKey>> tagTakePQ(CompletableFuture<Optional<KEMSignedPreKey>> prekey, final PQSource source, final boolean enrolledInPagedKeys) {
return prekey.thenApply(maybeSingleUsePreKey -> {
final Optional<String> maybeSourceTag = maybeSingleUsePreKey
// If we found a PK, use this source tag
.map(ignore -> source.name())
// If we didn't and this is our last resort, we didn't find a PK
.or(() -> source == PQSource.LAST_RESORT ? Optional.of("absent") : Optional.empty());
maybeSourceTag.ifPresent(sourceTag -> {
Metrics.counter(TAKE_PQ_NAME,
"source", sourceTag,
"enrolled", Boolean.toString(enrolledInPagedKeys))
.increment();
});
return maybeSingleUsePreKey;
});
}
public CompletableFuture<Optional<KEMSignedPreKey>> getLastResort(final UUID identifier, final byte deviceId) {
@ -118,20 +165,48 @@ public class KeysManager {
}
public CompletableFuture<Integer> getPqCount(final UUID identifier, final byte deviceId) {
return pqPreKeys.getCount(identifier, deviceId);
return pagedPqPreKeys.getCount(identifier, deviceId).thenCompose(count -> count == 0
? pqPreKeys.getCount(identifier, deviceId)
: CompletableFuture.completedFuture(count));
}
public CompletableFuture<Void> deleteSingleUsePreKeys(final UUID identifier) {
return CompletableFuture.allOf(
ecPreKeys.delete(identifier),
pqPreKeys.delete(identifier)
pqPreKeys.delete(identifier),
pagedPqPreKeys.delete(identifier)
);
}
public CompletableFuture<Void> deleteSingleUsePreKeys(final UUID accountUuid, final byte deviceId) {
return CompletableFuture.allOf(
ecPreKeys.delete(accountUuid, deviceId),
pqPreKeys.delete(accountUuid, deviceId)
ecPreKeys.delete(accountUuid, deviceId),
pqPreKeys.delete(accountUuid, deviceId),
pagedPqPreKeys.delete(accountUuid, deviceId)
);
}
/**
* List all the current remotely stored prekey pages across all devices. Pages that are no longer in use can be
* removed with {@link #pruneDeadPage}
*
* @param lookupConcurrency the number of concurrent lookup operations to perform when populating list results
* @return All stored prekey pages
*/
public Flux<DeviceKEMPreKeyPages> listStoredKEMPreKeyPages(int lookupConcurrency) {
return pagedPqPreKeys.listStoredPages(lookupConcurrency);
}
/**
* Remove a prekey page that is no longer in use. A page should only be removed if it is not the active page and
* it has no chance of being updated to be.
*
* @param identifier The owner of the dead page
* @param deviceId The device of the dead page
* @param pageId The dead page to remove from storage
* @return A future that completes when the page has been removed
*/
public CompletableFuture<Void> pruneDeadPage(final UUID identifier, final byte deviceId, final UUID pageId) {
return pagedPqPreKeys.deleteBundleFromS3(identifier, deviceId, pageId);
}
}

View File

@ -15,8 +15,6 @@ import io.lettuce.core.Range;
import io.lettuce.core.ScoredValue;
import io.lettuce.core.ZAddArgs;
import io.lettuce.core.cluster.SlotHash;
import io.lettuce.core.cluster.models.partitions.ClusterPartitionParser;
import io.lettuce.core.cluster.models.partitions.Partitions;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
@ -260,7 +258,7 @@ public class MessagesCache {
for (final byte[] bytes : serialized) {
try {
final MessageProtos.Envelope envelope = MessageProtos.Envelope.parseFrom(bytes);
final MessageProtos.Envelope envelope = parseEnvelope(bytes);
removedMessages.add(RemovedMessage.fromEnvelope(envelope));
if (envelope.hasSharedMrmKey()) {
serviceIdentifierToMrmKeys.computeIfAbsent(
@ -387,7 +385,7 @@ public class MessagesCache {
for (int i = 0; i < queueItems.size() - 1; i += 2) {
try {
final MessageProtos.Envelope message = MessageProtos.Envelope.parseFrom(queueItems.get(i));
final MessageProtos.Envelope message = parseEnvelope(queueItems.get(i));
final Mono<MessageProtos.Envelope> messageMono;
if (message.hasSharedMrmKey()) {
@ -606,7 +604,7 @@ public class MessagesCache {
return serializedMessages
.mapNotNull(message -> {
try {
return MessageProtos.Envelope.parseFrom(message);
return parseEnvelope(message);
} catch (InvalidProtocolBufferException e) {
logger.warn("Failed to parse envelope", e);
return null;
@ -646,7 +644,7 @@ public class MessagesCache {
final List<String> processedMessages = new ArrayList<>(messagesToProcess.size());
for (byte[] serialized : messagesToProcess) {
try {
final MessageProtos.Envelope message = MessageProtos.Envelope.parseFrom(serialized);
final MessageProtos.Envelope message = parseEnvelope(serialized);
processedMessages.add(message.getServerGuid());
@ -754,4 +752,10 @@ public class MessagesCache {
static byte getDeviceIdFromQueueName(final String queueName) {
return Byte.parseByte(queueName.substring(queueName.lastIndexOf("::") + 2, queueName.lastIndexOf('}')));
}
private static MessageProtos.Envelope parseEnvelope(final byte[] envelopeBytes)
throws InvalidProtocolBufferException {
return EnvelopeUtil.expand(MessageProtos.Envelope.parseFrom(envelopeBytes));
}
}

View File

@ -57,7 +57,7 @@ class MessagesCacheInsertScript {
);
final List<byte[]> args = new ArrayList<>(Arrays.asList(
envelope.toByteArray(), // message
EnvelopeUtil.compress(envelope).toByteArray(), // message
String.valueOf(envelope.getServerTimestamp()).getBytes(StandardCharsets.UTF_8), // currentTime
envelope.getServerGuid().getBytes(StandardCharsets.UTF_8), // guid
NEW_MESSAGE_EVENT_BYTES // eventPayload

View File

@ -105,7 +105,7 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
.put(KEY_SORT, convertSortKey(message.getServerTimestamp(), messageUuid))
.put(LOCAL_INDEX_MESSAGE_UUID_KEY_SORT, convertLocalIndexMessageUuidSortKey(messageUuid))
.put(KEY_TTL, AttributeValues.fromLong(getTtlForMessage(message)))
.put(KEY_ENVELOPE_BYTES, AttributeValue.builder().b(SdkBytes.fromByteArray(message.toByteArray())).build());
.put(KEY_ENVELOPE_BYTES, AttributeValue.builder().b(SdkBytes.fromByteArray(EnvelopeUtil.compress(message).toByteArray())).build());
writeItems.add(WriteRequest.builder().putRequest(PutRequest.builder()
.item(item.build())
@ -227,7 +227,7 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
static MessageProtos.Envelope convertItemToEnvelope(final Map<String, AttributeValue> item)
throws InvalidProtocolBufferException {
return MessageProtos.Envelope.parseFrom(item.get(KEY_ENVELOPE_BYTES).b().asByteArray());
return EnvelopeUtil.expand(MessageProtos.Envelope.parseFrom(item.get(KEY_ENVELOPE_BYTES).b().asByteArray()));
}
private long getTtlForMessage(MessageProtos.Envelope message) {

View File

@ -0,0 +1,430 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.Util;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import software.amazon.awssdk.core.async.AsyncRequestBody;
import software.amazon.awssdk.core.async.AsyncResponseTransformer;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Object;
/**
* @implNote This version of a {@link SingleUsePreKeyStore} store bundles prekeys into "pages", which are stored in on
* an object store and referenced via dynamodb. Each device may only have a single active page at a time. Crashes or
* errors may leave orphaned pages which are no longer referenced by the database. A background process must
* periodically check for orphaned pages and remove them.
* @see SingleUsePreKeyStore
*/
public class PagedSingleUseKEMPreKeyStore {
private static final Logger log = LoggerFactory.getLogger(PagedSingleUseKEMPreKeyStore.class);
private final DynamoDbAsyncClient dynamoDbAsyncClient;
private final S3AsyncClient s3AsyncClient;
private final String tableName;
private final String bucketName;
private final Timer getKeyCountTimer = Metrics.timer(name(getClass(), "getCount"));
private final Timer storeKeyBatchTimer = Metrics.timer(name(getClass(), "storeKeyBatch"));
private final Timer deleteForDeviceTimer = Metrics.timer(name(getClass(), "deleteForDevice"));
private final Timer deleteForAccountTimer = Metrics.timer(name(getClass(), "deleteForAccount"));
final DistributionSummary availableKeyCountDistributionSummary = DistributionSummary
.builder(name(getClass(), "availableKeyCount"))
.publishPercentileHistogram()
.register(Metrics.globalRegistry);
private final String takeKeyTimerName = name(getClass(), "takeKey");
private static final String KEY_PRESENT_TAG_NAME = "keyPresent";
static final String KEY_ACCOUNT_UUID = "U";
static final String KEY_DEVICE_ID = "D";
static final String ATTR_PAGE_ID = "ID";
static final String ATTR_PAGE_IDX = "I";
static final String ATTR_PAGE_NUM_KEYS = "N";
static final String ATTR_PAGE_FORMAT_VERSION = "F";
public PagedSingleUseKEMPreKeyStore(
final DynamoDbAsyncClient dynamoDbAsyncClient,
final S3AsyncClient s3AsyncClient,
final String tableName,
final String bucketName) {
this.s3AsyncClient = s3AsyncClient;
this.dynamoDbAsyncClient = dynamoDbAsyncClient;
this.tableName = tableName;
this.bucketName = bucketName;
}
/**
* Stores a batch of single-use pre-keys for a specific device. All previously-stored keys for the device are cleared
* before storing new keys.
*
* @param identifier the identifier for the account/identity with which the target device is associated
* @param deviceId the identifier for the device within the given account/identity
* @param preKeys a collection of single-use pre-keys to store for the target device
* @return a future that completes when all previously-stored keys have been removed and the given collection of
* pre-keys has been stored in its place
*/
public CompletableFuture<Void> store(
final UUID identifier, final byte deviceId, final List<KEMSignedPreKey> preKeys) {
final Timer.Sample sample = Timer.start();
final List<KEMSignedPreKey> sorted = preKeys.stream().sorted(Comparator.comparing(KEMSignedPreKey::keyId)).toList();
final int bundleFormat = KEMPreKeyPage.FORMAT;
final ByteBuffer bundle = KEMPreKeyPage.serialize(KEMPreKeyPage.FORMAT, sorted);
// Write the bundle to S3, then update the database. Delete the S3 object that was in the database before. This can
// leave orphans in S3 if we fail to update after writing to S3, or fail to delete the old page. However, it can
// never leave a broken pointer in the database. To keep this invariant, we must make sure to generate a new
// name for the page any time we were to retry this entire operation.
return writeBundleToS3(identifier, deviceId, bundle)
.thenCompose(pageId -> dynamoDbAsyncClient.putItem(PutItemRequest.builder()
.tableName(tableName)
.item(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),
KEY_DEVICE_ID, AttributeValues.fromInt(deviceId),
ATTR_PAGE_ID, AttributeValues.fromUUID(pageId),
ATTR_PAGE_IDX, AttributeValues.fromInt(0),
ATTR_PAGE_NUM_KEYS, AttributeValues.fromInt(sorted.size()),
ATTR_PAGE_FORMAT_VERSION, AttributeValues.fromInt(bundleFormat)
))
.returnValues(ReturnValue.ALL_OLD)
.build()))
.thenCompose(response -> {
if (response.hasAttributes()) {
final UUID pageId = AttributeValues.getUUID(response.attributes(), ATTR_PAGE_ID, null);
if (pageId == null) {
log.error("Replaced record: {} with no pageId", response.attributes());
return CompletableFuture.completedFuture(null);
}
return deleteBundleFromS3(identifier, deviceId, pageId);
} else {
return CompletableFuture.completedFuture(null);
}
})
.whenComplete((result, error) -> sample.stop(storeKeyBatchTimer));
}
/**
* Attempts to retrieve a single-use pre-key for a specific device. Keys may only be returned by this method at most
* once; once the key is returned, it is removed from the key store and subsequent calls to this method will never
* return the same key.
*
* @param identifier the identifier for the account/identity with which the target device is associated
* @param deviceId the identifier for the device within the given account/identity
* @return a future that yields a single-use pre-key if one is available or empty if no single-use pre-keys are
* available for the target device
*/
public CompletableFuture<Optional<KEMSignedPreKey>> take(final UUID identifier, final byte deviceId) {
final Timer.Sample sample = Timer.start();
return dynamoDbAsyncClient.updateItem(UpdateItemRequest.builder()
.tableName(tableName)
.key(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),
KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)))
.updateExpression("SET #index = #index + :one")
.conditionExpression("#id = :id AND #index < #numkeys")
.expressionAttributeNames(Map.of(
"#id", KEY_ACCOUNT_UUID,
"#index", ATTR_PAGE_IDX,
"#numkeys", ATTR_PAGE_NUM_KEYS))
.expressionAttributeValues(Map.of(
":one", AttributeValues.n(1),
":id", AttributeValues.fromUUID(identifier)))
.returnValues(ReturnValue.ALL_OLD)
.build())
.thenCompose(updateItemResponse -> {
if (!updateItemResponse.hasAttributes()) {
throw new IllegalStateException("update succeeded but did not return an item");
}
final int index = AttributeValues.getInt(updateItemResponse.attributes(), ATTR_PAGE_IDX, -1);
final UUID pageId = AttributeValues.getUUID(updateItemResponse.attributes(), ATTR_PAGE_ID, null);
final int format = AttributeValues.getInt(updateItemResponse.attributes(), ATTR_PAGE_FORMAT_VERSION, -1);
if (index < 0 || format < 0 || pageId == null) {
throw new CompletionException(
new IOException("unexpected page descriptor " + updateItemResponse.attributes()));
}
return readPreKeyAtIndexFromS3(identifier, deviceId, pageId, format, index).thenApply(Optional::of);
})
// If this check fails, it means that the item did not exist, or its index was already at the last key. Either
// way, there are no keys left so we return empty
.exceptionally(ExceptionUtils.exceptionallyHandler(
ConditionalCheckFailedException.class,
e -> Optional.empty()))
.whenComplete((maybeKey, throwable) ->
sample.stop(Metrics.timer(
takeKeyTimerName,
KEY_PRESENT_TAG_NAME, String.valueOf(maybeKey != null && maybeKey.isPresent()))));
}
/**
* Returns the number of single-use pre-keys available for a given device.
*
* @param identifier the identifier for the account/identity with which the target device is associated
* @param deviceId the identifier for the device within the given account/identity
* @return a future that yields the approximate number of single-use pre-keys currently available for the target
* device
*/
public CompletableFuture<Integer> getCount(final UUID identifier, final byte deviceId) {
final Timer.Sample sample = Timer.start();
return dynamoDbAsyncClient.getItem(GetItemRequest.builder()
.tableName(tableName)
.key(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),
KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)))
.consistentRead(true)
.projectionExpression("#total, #index")
.expressionAttributeNames(Map.of(
"#total", ATTR_PAGE_NUM_KEYS,
"#index", ATTR_PAGE_IDX))
.build())
.thenApply(getResponse -> {
if (!getResponse.hasItem()) {
return 0;
}
final int numKeys = AttributeValues.getInt(getResponse.item(), ATTR_PAGE_NUM_KEYS, -1);
final int index = AttributeValues.getInt(getResponse.item(), ATTR_PAGE_IDX, -1);
if (numKeys < 0 || index < 0 || index > numKeys) {
log.error("unexpected index/length in page descriptor: {}", getResponse.item());
return 0;
}
return numKeys - index;
})
.whenComplete((keyCount, throwable) -> {
sample.stop(getKeyCountTimer);
if (throwable == null && keyCount != null) {
availableKeyCountDistributionSummary.record(keyCount);
}
});
}
/**
* Removes all single-use pre-keys for all devices associated with the given account/identity.
*
* @param identifier the identifier for the account/identity for which to remove single-use pre-keys
* @return a future that completes when all single-use pre-keys have been removed for all devices associated with the
* given account/identity
*/
public CompletableFuture<Void> delete(final UUID identifier) {
final Timer.Sample sample = Timer.start();
return deleteItems(identifier, Flux.from(dynamoDbAsyncClient.queryPaginator(QueryRequest.builder()
.tableName(tableName)
.keyConditionExpression("#uuid = :uuid")
.projectionExpression("#uuid,#deviceid,#pageid")
.expressionAttributeNames(Map.of(
"#uuid", KEY_ACCOUNT_UUID,
"#deviceid", KEY_DEVICE_ID,
"#pageid", ATTR_PAGE_ID))
.expressionAttributeValues(Map.of(":uuid", AttributeValues.fromUUID(identifier)))
.consistentRead(true)
.build())
.items()))
.thenRun(() -> sample.stop(deleteForAccountTimer));
}
/**
* Removes all single-use pre-keys for a specific device.
*
* @param identifier the identifier for the account/identity with which the target device is associated
* @param deviceId the identifier for the device within the given account/identity
* @return a future that completes when all single-use pre-keys have been removed for the target device
*/
public CompletableFuture<Void> delete(final UUID identifier, final byte deviceId) {
final Timer.Sample sample = Timer.start();
return dynamoDbAsyncClient.getItem(GetItemRequest.builder()
.tableName(tableName)
.key(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),
KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)))
.consistentRead(true)
.projectionExpression("#uuid,#deviceid,#pageid")
.expressionAttributeNames(Map.of(
"#uuid", KEY_ACCOUNT_UUID,
"#deviceid", KEY_DEVICE_ID,
"#pageid", ATTR_PAGE_ID))
.build())
.thenCompose(getItemResponse -> deleteItems(identifier, getItemResponse.hasItem()
? Flux.just(getItemResponse.item())
: Flux.empty()))
.thenRun(() -> sample.stop(deleteForDeviceTimer));
}
public Flux<DeviceKEMPreKeyPages> listStoredPages(int lookupConcurrency) {
return Flux
.from(s3AsyncClient.listObjectsV2Paginator(ListObjectsV2Request.builder()
.bucket(bucketName)
.build()))
.flatMapIterable(ListObjectsV2Response::contents)
.map(PagedSingleUseKEMPreKeyStore::parseS3Key)
.bufferUntilChanged(Function.identity(), S3PageKey::fromSameDevice)
.flatMapSequential(pages -> {
final UUID identifier = pages.getFirst().identifier();
final byte deviceId = pages.getFirst().deviceId();
return Mono.fromCompletionStage(() -> dynamoDbAsyncClient.getItem(GetItemRequest.builder()
.tableName(tableName)
.key(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),
KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)))
// Make sure we get the most up to date pageId to minimize cases where we see a new page in S3 but
// view a stale dynamodb record
.consistentRead(true)
.projectionExpression("#uuid,#deviceid,#pageid")
.expressionAttributeNames(Map.of(
"#uuid", KEY_ACCOUNT_UUID,
"#deviceid", KEY_DEVICE_ID,
"#pageid", ATTR_PAGE_ID))
.build())
.thenApply(getItemResponse -> new DeviceKEMPreKeyPages(
identifier,
deviceId,
Optional.ofNullable(AttributeValues.getUUID(getItemResponse.item(), ATTR_PAGE_ID, null)),
pages.stream().collect(Collectors.toMap(S3PageKey::pageId, S3PageKey::lastModified)))));
}, lookupConcurrency);
}
private CompletableFuture<Void> deleteItems(final UUID identifier,
final Flux<Map<String, AttributeValue>> items) {
return items
.flatMap(item -> {
final UUID aci = AttributeValues.getUUID(item, KEY_ACCOUNT_UUID, null);
final byte deviceId = (byte) AttributeValues.getInt(item, KEY_DEVICE_ID, -1);
final UUID pageId = AttributeValues.getUUID(item, ATTR_PAGE_ID, null);
if (aci == null || deviceId < 0 || pageId == null) {
log.error("can't delete page from unexpected page descriptor {}", item);
}
return Mono.fromFuture(deleteBundleFromS3(aci, deviceId, pageId))
.thenReturn(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),
KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)));
})
.flatMap(itemToDelete -> Mono.fromFuture(() -> dynamoDbAsyncClient.deleteItem(DeleteItemRequest.builder()
.tableName(tableName)
.key(itemToDelete)
.build())))
.then()
.toFuture()
.thenRun(Util.NOOP);
}
private static String s3Key(final UUID identifier, final byte deviceId, final UUID pageId) {
return String.format("%s/%s/%s", identifier, deviceId, pageId);
}
private record S3PageKey(UUID identifier, byte deviceId, UUID pageId, Instant lastModified) {
boolean fromSameDevice(final S3PageKey other) {
return deviceId == other.deviceId && identifier.equals(other.identifier);
}
}
private static S3PageKey parseS3Key(final S3Object page) {
try {
final String[] parts = page.key().split("/", 3);
if (parts.length != 3 || parts[2].contains("/")) {
throw new IllegalArgumentException("wrong number of path components");
}
return new S3PageKey(
UUID.fromString(parts[0]),
Byte.parseByte(parts[1]),
UUID.fromString(parts[2]), page.lastModified());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("invalid s3 page key: " + page.key(), e);
}
}
private CompletableFuture<UUID> writeBundleToS3(final UUID identifier, final byte deviceId,
final ByteBuffer bundle) {
final UUID pageId = UUID.randomUUID();
return s3AsyncClient.putObject(PutObjectRequest.builder()
.bucket(bucketName)
.key(s3Key(identifier, deviceId, pageId)).build(),
AsyncRequestBody.fromByteBuffer(bundle))
.thenApply(ignoredResponse -> pageId);
}
CompletableFuture<Void> deleteBundleFromS3(final UUID identifier, final byte deviceId, final UUID pageId) {
return s3AsyncClient.deleteObject(DeleteObjectRequest.builder()
.bucket(bucketName)
.key(s3Key(identifier, deviceId, pageId))
.build())
.thenRun(Util.NOOP);
}
private CompletableFuture<KEMSignedPreKey> readPreKeyAtIndexFromS3(
final UUID identifier, final byte deviceId, final UUID pageId, final int format, final int index) {
final KEMPreKeyPage.KeyLocation keyLocation = KEMPreKeyPage.keyLocation(format, index);
return s3AsyncClient.getObject(GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key(identifier, deviceId, pageId))
// An RFC9110 range header, inclusive on both ends
// https://www.rfc-editor.org/rfc/rfc9110.html#section-14.1.2
.range("bytes=%s-%s".formatted(keyLocation.getStartInclusive(), keyLocation.getEndInclusive()))
.build(), AsyncResponseTransformer.toBytes())
.thenApply(bytes -> {
final ByteBuffer serialized = bytes.asByteBuffer();
if (serialized.remaining() != keyLocation.length()) {
log.error("Unexpected ranged read response, requested {} got {} for offset {} in page {}",
keyLocation.length(), serialized.remaining(), keyLocation, s3Key(identifier, deviceId, pageId));
throw new CompletionException(new IOException("Invalid response to ranged read"));
}
try {
return KEMPreKeyPage.deserializeKey(format, bytes.asByteBuffer());
} catch (InvalidKeyException e) {
throw new CompletionException(new IOException(e));
}
});
}
}

View File

@ -19,7 +19,7 @@ import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
public class SingleUseECPreKeyStore extends SingleUsePreKeyStore<ECPreKey> {
private static final String PARSE_BYTE_ARRAY_COUNTER_NAME = name(SingleUseECPreKeyStore.class, "parseByteArray");
protected SingleUseECPreKeyStore(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) {
public SingleUseECPreKeyStore(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) {
super(dynamoDbAsyncClient, tableName);
}

View File

@ -16,7 +16,7 @@ import java.util.UUID;
public class SingleUseKEMPreKeyStore extends SingleUsePreKeyStore<KEMSignedPreKey> {
protected SingleUseKEMPreKeyStore(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) {
public SingleUseKEMPreKeyStore(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) {
super(dynamoDbAsyncClient, tableName);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.websocket;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import io.micrometer.core.instrument.Tags;
import java.util.Optional;
import java.util.concurrent.ScheduledExecutorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -20,7 +21,10 @@ import org.whispersystems.textsecuregcm.push.WebSocketConnectionEventManager;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.push.PushNotificationScheduler;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.websocket.session.WebSocketSessionContext;
import org.whispersystems.websocket.setup.WebSocketConnectListener;
@ -36,6 +40,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
private static final Logger log = LoggerFactory.getLogger(AuthenticatedConnectListener.class);
private final AccountsManager accountsManager;
private final ReceiptSender receiptSender;
private final MessagesManager messagesManager;
private final MessageMetrics messageMetrics;
@ -51,7 +56,9 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
private final OpenWebSocketCounter openAuthenticatedWebSocketCounter;
private final OpenWebSocketCounter openUnauthenticatedWebSocketCounter;
public AuthenticatedConnectListener(ReceiptSender receiptSender,
public AuthenticatedConnectListener(
AccountsManager accountsManager,
ReceiptSender receiptSender,
MessagesManager messagesManager,
MessageMetrics messageMetrics,
PushNotificationManager pushNotificationManager,
@ -62,6 +69,8 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
ClientReleaseManager clientReleaseManager,
MessageDeliveryLoopMonitor messageDeliveryLoopMonitor,
final ExperimentEnrollmentManager experimentEnrollmentManager) {
this.accountsManager = accountsManager;
this.receiptSender = receiptSender;
this.messagesManager = messagesManager;
this.messageMetrics = messageMetrics;
@ -82,7 +91,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
}
@Override
public void onWebSocketConnect(WebSocketSessionContext context) {
public void onWebSocketConnect(final WebSocketSessionContext context) {
final boolean authenticated = (context.getAuthenticated() != null);
final OpenWebSocketCounter openWebSocketCounter =
@ -92,12 +101,24 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
if (authenticated) {
final AuthenticatedDevice auth = context.getAuthenticated(AuthenticatedDevice.class);
final Optional<Account> maybeAuthenticatedAccount = accountsManager.getByAccountIdentifier(auth.accountIdentifier());
final Optional<Device> maybeAuthenticatedDevice = maybeAuthenticatedAccount.flatMap(account -> account.getDevice(auth.deviceId()));;
if (maybeAuthenticatedAccount.isEmpty() || maybeAuthenticatedDevice.isEmpty()) {
log.warn("{}:{} not found when opening authenticated WebSocket", auth.accountIdentifier(), auth.deviceId());
context.getClient().close(1011, "Unexpected error initializing connection");
return;
}
final WebSocketConnection connection = new WebSocketConnection(receiptSender,
messagesManager,
messageMetrics,
pushNotificationManager,
pushNotificationScheduler,
auth,
maybeAuthenticatedAccount.get(),
maybeAuthenticatedDevice.get(),
context.getClient(),
scheduledExecutorService,
messageDeliveryScheduler,
@ -110,8 +131,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
// receive push notifications for inbound messages. We should do this first because, at this point, the
// connection has already closed and attempts to actually deliver a message via the connection will not succeed.
// It's preferable to start sending push notifications as soon as possible.
webSocketConnectionEventManager.handleClientDisconnected(auth.getAccount().getUuid(),
auth.getAuthenticatedDevice().getId());
webSocketConnectionEventManager.handleClientDisconnected(auth.accountIdentifier(), auth.deviceId());
// Finally, stop trying to deliver messages and send a push notification if the connection is aware of any
// undelivered messages.
@ -127,7 +147,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
// Finally, we register this client's presence, which suppresses push notifications. We do this last because
// receiving extra push notifications is generally preferable to missing out on a push notification.
webSocketConnectionEventManager.handleClientConnected(auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId(), connection);
webSocketConnectionEventManager.handleClientConnected(auth.accountIdentifier(), auth.deviceId(), connection);
} catch (final Exception e) {
log.warn("Failed to initialize websocket", e);
context.getClient().close(1011, "Unexpected error initializing connection");

View File

@ -9,41 +9,39 @@ import static org.whispersystems.textsecuregcm.util.HeaderUtils.basicCredentials
import com.google.common.net.HttpHeaders;
import javax.annotation.Nullable;
import io.dropwizard.auth.basic.BasicCredentials;
import org.eclipse.jetty.websocket.api.UpgradeRequest;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.websocket.ReusableAuth;
import org.whispersystems.websocket.auth.InvalidCredentialsException;
import org.whispersystems.websocket.auth.PrincipalSupplier;
import org.whispersystems.websocket.auth.WebSocketAuthenticator;
import java.util.Optional;
public class WebSocketAccountAuthenticator implements WebSocketAuthenticator<AuthenticatedDevice> {
private static final ReusableAuth<AuthenticatedDevice> CREDENTIALS_NOT_PRESENTED = ReusableAuth.anonymous();
private final AccountAuthenticator accountAuthenticator;
private final PrincipalSupplier<AuthenticatedDevice> principalSupplier;
public WebSocketAccountAuthenticator(final AccountAuthenticator accountAuthenticator,
final PrincipalSupplier<AuthenticatedDevice> principalSupplier) {
public WebSocketAccountAuthenticator(final AccountAuthenticator accountAuthenticator) {
this.accountAuthenticator = accountAuthenticator;
this.principalSupplier = principalSupplier;
}
@Override
public ReusableAuth<AuthenticatedDevice> authenticate(final UpgradeRequest request)
public Optional<AuthenticatedDevice> authenticate(final UpgradeRequest request)
throws InvalidCredentialsException {
@Nullable final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null) {
return CREDENTIALS_NOT_PRESENTED;
return Optional.empty();
}
return basicCredentialsFromAuthHeader(authHeader)
.flatMap(accountAuthenticator::authenticate)
.map(authenticatedAccount -> ReusableAuth.authenticated(authenticatedAccount, this.principalSupplier))
final BasicCredentials credentials = basicCredentialsFromAuthHeader(authHeader)
.orElseThrow(InvalidCredentialsException::new);
final AuthenticatedDevice authenticatedDevice = accountAuthenticator.authenticate(credentials)
.orElseThrow(InvalidCredentialsException::new);
return Optional.of(authenticatedDevice);
}
}

View File

@ -37,7 +37,6 @@ import org.eclipse.jetty.util.StaticException;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.controllers.MessageController;
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
@ -52,6 +51,7 @@ import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.push.PushNotificationScheduler;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.push.WebSocketConnectionEventListener;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
@ -123,7 +123,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
private final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor;
private final ExperimentEnrollmentManager experimentEnrollmentManager;
private final AuthenticatedDevice auth;
private final Account authenticatedAccount;
private final Device authenticatedDevice;
private final WebSocketClient client;
private final int sendFuturesTimeoutMillis;
@ -156,7 +157,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
MessageMetrics messageMetrics,
PushNotificationManager pushNotificationManager,
PushNotificationScheduler pushNotificationScheduler,
AuthenticatedDevice auth,
Account authenticatedAccount,
Device authenticatedDevice,
WebSocketClient client,
ScheduledExecutorService scheduledExecutorService,
Scheduler messageDeliveryScheduler,
@ -169,7 +171,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
messageMetrics,
pushNotificationManager,
pushNotificationScheduler,
auth,
authenticatedAccount,
authenticatedDevice,
client,
DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS,
scheduledExecutorService,
@ -184,7 +187,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
MessageMetrics messageMetrics,
PushNotificationManager pushNotificationManager,
PushNotificationScheduler pushNotificationScheduler,
AuthenticatedDevice auth,
Account authenticatedAccount,
Device authenticatedDevice,
WebSocketClient client,
int sendFuturesTimeoutMillis,
ScheduledExecutorService scheduledExecutorService,
@ -198,7 +202,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
this.messageMetrics = messageMetrics;
this.pushNotificationManager = pushNotificationManager;
this.pushNotificationScheduler = pushNotificationScheduler;
this.auth = auth;
this.authenticatedAccount = authenticatedAccount;
this.authenticatedDevice = authenticatedDevice;
this.client = client;
this.sendFuturesTimeoutMillis = sendFuturesTimeoutMillis;
this.scheduledExecutorService = scheduledExecutorService;
@ -209,7 +214,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
}
public void start() {
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), client.getUserAgent());
pushNotificationManager.handleMessagesRetrieved(authenticatedAccount, authenticatedDevice, client.getUserAgent());
queueDrainStartTime.set(System.currentTimeMillis());
processStoredMessages();
}
@ -229,8 +234,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
client.close(1000, "OK");
if (storedMessageState.get() != StoredMessageState.EMPTY) {
pushNotificationScheduler.scheduleDelayedNotification(auth.getAccount(),
auth.getAuthenticatedDevice(),
pushNotificationScheduler.scheduleDelayedNotification(authenticatedAccount,
authenticatedDevice,
CLOSE_WITH_PENDING_MESSAGES_NOTIFICATION_DELAY);
}
}
@ -242,7 +247,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
sendMessageCounter.increment();
sentMessageCounter.increment();
bytesSentCounter.increment(body.map(bytes -> bytes.length).orElse(0));
messageMetrics.measureAccountEnvelopeUuidMismatches(auth.getAccount(), message);
messageMetrics.measureAccountEnvelopeUuidMismatches(authenticatedAccount, message);
// X-Signal-Key: false must be sent until Android stops assuming it missing means true
return client.sendRequest("PUT", "/api/v1/message",
@ -253,7 +258,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
} else {
messageMetrics.measureOutgoingMessageLatency(message.getServerTimestamp(),
"websocket",
auth.getAuthenticatedDevice().isPrimary(),
authenticatedDevice.isPrimary(),
message.getUrgent(),
message.getEphemeral(),
client.getUserAgent(),
@ -263,12 +268,12 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
final CompletableFuture<Void> result;
if (isSuccessResponse(response)) {
result = messagesManager.delete(auth.getAccount().getUuid(), auth.getAuthenticatedDevice(),
result = messagesManager.delete(authenticatedAccount.getIdentifier(IdentityType.ACI), authenticatedDevice,
storedMessageInfo.guid(), storedMessageInfo.serverTimestamp())
.thenApply(ignored -> null);
if (message.getType() != Envelope.Type.SERVER_DELIVERY_RECEIPT) {
recordMessageDeliveryDuration(message.getServerTimestamp(), auth.getAuthenticatedDevice());
recordMessageDeliveryDuration(message.getServerTimestamp(), authenticatedDevice);
sendDeliveryReceiptFor(message);
}
} else {
@ -307,7 +312,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
try {
receiptSender.sendReceipt(ServiceIdentifier.valueOf(message.getDestinationServiceId()),
auth.getAuthenticatedDevice().getId(), AciServiceIdentifier.valueOf(message.getSourceServiceId()),
authenticatedDevice.getId(), AciServiceIdentifier.valueOf(message.getSourceServiceId()),
message.getClientTimestamp());
} catch (IllegalArgumentException e) {
logger.error("Could not parse UUID: {}", message.getSourceServiceId());
@ -338,7 +343,6 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
// Cleared the queue! Send a queue empty message if we need to
consecutiveRetries.set(0);
if (sentInitialQueueEmptyMessage.compareAndSet(false, true)) {
final Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(client.getUserAgent()));
final long drainDuration = System.currentTimeMillis() - queueDrainStartTime.get();
@ -399,7 +403,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
final CompletableFuture<Void> queueCleared = new CompletableFuture<>();
final Publisher<Envelope> messages =
messagesManager.getMessagesForDeviceReactive(auth.getAccount().getUuid(), auth.getAuthenticatedDevice(), cachedMessagesOnly);
messagesManager.getMessagesForDeviceReactive(authenticatedAccount.getIdentifier(IdentityType.ACI), authenticatedDevice, cachedMessagesOnly);
final AtomicBoolean hasSentFirstMessage = new AtomicBoolean();
final AtomicBoolean hasErrored = new AtomicBoolean();
@ -410,8 +414,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
.limitRate(MESSAGE_PUBLISHER_LIMIT_RATE)
.doOnNext(envelope -> {
if (hasSentFirstMessage.compareAndSet(false, true)) {
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.getAccount().getIdentifier(IdentityType.ACI),
auth.getAuthenticatedDevice().getId(),
messageDeliveryLoopMonitor.recordDeliveryAttempt(authenticatedAccount.getIdentifier(IdentityType.ACI),
authenticatedDevice.getId(),
UUID.fromString(envelope.getServerGuid()),
client.getUserAgent(),
"websocket");
@ -471,7 +475,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
final UUID messageGuid = UUID.fromString(envelope.getServerGuid());
if (envelope.getStory() && !client.shouldDeliverStories()) {
messagesManager.delete(auth.getAccount().getUuid(), auth.getAuthenticatedDevice(), messageGuid, envelope.getServerTimestamp());
messagesManager.delete(authenticatedAccount.getIdentifier(IdentityType.ACI), authenticatedDevice, messageGuid, envelope.getServerTimestamp());
return CompletableFuture.completedFuture(null);
} else {

View File

@ -33,6 +33,7 @@ import org.whispersystems.textsecuregcm.backup.Cdn3RemoteStorageManager;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.MicrometerAwsSdkMetricPublisher;
@ -57,13 +58,18 @@ import org.whispersystems.textsecuregcm.storage.KeysManager;
import org.whispersystems.textsecuregcm.storage.MessagesCache;
import org.whispersystems.textsecuregcm.storage.MessagesDynamoDb;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PagedSingleUseKEMPreKeyStore;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.RepeatedUseECSignedPreKeyStore;
import org.whispersystems.textsecuregcm.storage.RepeatedUseKEMSignedPreKeyStore;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.SingleUseECPreKeyStore;
import org.whispersystems.textsecuregcm.storage.SingleUseKEMPreKeyStore;
import org.whispersystems.textsecuregcm.util.ManagedAwsCrt;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
@ -117,6 +123,9 @@ record CommandDependencies(
new DynamicConfigurationManager<>(
configuration.getDynamicConfig().build(awsCredentialsProvider, dynamicConfigurationExecutor), DynamicConfiguration.class);
dynamicConfigurationManager.start();
ExperimentEnrollmentManager experimentEnrollmentManager =
new ExperimentEnrollmentManager(dynamicConfigurationManager);
final ClientResources.Builder redisClientResourcesBuilder = ClientResources.builder();
FaultTolerantRedisClusterClient cacheCluster = configuration.getCacheClusterConfiguration()
@ -204,13 +213,23 @@ record CommandDependencies(
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfiles().getTableName());
S3AsyncClient asyncKeysS3Client = S3AsyncClient.builder()
.credentialsProvider(awsCredentialsProvider)
.region(Region.of(configuration.getPagedSingleUseKEMPreKeyStore().region()))
.build();
PagedSingleUseKEMPreKeyStore pagedSingleUseKEMPreKeyStore = new PagedSingleUseKEMPreKeyStore(
dynamoDbAsyncClient, asyncKeysS3Client,
configuration.getDynamoDbTables().getPagedKemKeys().getTableName(),
configuration.getPagedSingleUseKEMPreKeyStore().bucket());
KeysManager keys = new KeysManager(
dynamoDbAsyncClient,
configuration.getDynamoDbTables().getEcKeys().getTableName(),
configuration.getDynamoDbTables().getKemKeys().getTableName(),
configuration.getDynamoDbTables().getEcSignedPreKeys().getTableName(),
configuration.getDynamoDbTables().getKemLastResortKeys().getTableName()
);
new SingleUseECPreKeyStore(dynamoDbAsyncClient, configuration.getDynamoDbTables().getEcKeys().getTableName()),
new SingleUseKEMPreKeyStore(dynamoDbAsyncClient, configuration.getDynamoDbTables().getKemKeys().getTableName()),
pagedSingleUseKEMPreKeyStore,
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,
configuration.getDynamoDbTables().getEcSignedPreKeys().getTableName()),
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,
configuration.getDynamoDbTables().getKemLastResortKeys().getTableName()),
experimentEnrollmentManager);
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getMessages().getTableName(),
configuration.getDynamoDbTables().getMessages().getExpiration(),

View File

@ -0,0 +1,143 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.workers;
import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.core.Application;
import io.dropwizard.core.setup.Environment;
import io.micrometer.core.instrument.Metrics;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.DeviceKEMPreKeyPages;
import org.whispersystems.textsecuregcm.storage.KeysManager;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
public class RemoveOrphanedPreKeyPagesCommand extends AbstractCommandWithDependencies {
private final Logger logger = LoggerFactory.getLogger(getClass());
private static final String PAGE_CONSIDERED_COUNTER_NAME = MetricsUtil.name(RemoveOrphanedPreKeyPagesCommand.class,
"pageConsidered");
@VisibleForTesting
static final String DRY_RUN_ARGUMENT = "dry-run";
@VisibleForTesting
static final String CONCURRENCY_ARGUMENT = "concurrency";
private static final int DEFAULT_CONCURRENCY = 10;
@VisibleForTesting
static final String MINIMUM_ORPHAN_AGE_ARGUMENT = "orphan-age";
private static final Duration DEFAULT_MINIMUM_ORPHAN_AGE = Duration.ofDays(7);
private final Clock clock;
public RemoveOrphanedPreKeyPagesCommand(final Clock clock) {
super(new Application<>() {
@Override
public void run(final WhisperServerConfiguration configuration, final Environment environment) {
}
}, "remove-orphaned-pre-key-pages", "Remove pre-key pages that are unreferenced");
this.clock = clock;
}
@Override
public void configure(final Subparser subparser) {
super.configure(subparser);
subparser.addArgument("--concurrency")
.type(Integer.class)
.dest(CONCURRENCY_ARGUMENT)
.required(false)
.setDefault(DEFAULT_CONCURRENCY)
.help("The maximum number of parallel dynamodb operations to process concurrently");
subparser.addArgument("--dry-run")
.type(Boolean.class)
.dest(DRY_RUN_ARGUMENT)
.required(false)
.setDefault(true)
.help("If true, don't actually remove orphaned pre-key pages");
subparser.addArgument("--minimum-orphan-age")
.type(String.class)
.dest(MINIMUM_ORPHAN_AGE_ARGUMENT)
.required(false)
.setDefault(DEFAULT_MINIMUM_ORPHAN_AGE.toString())
.help("Only remove orphans that are at least this old. Provide as an ISO-8601 duration string");
}
@Override
protected void run(final Environment environment, final Namespace namespace,
final WhisperServerConfiguration configuration, final CommandDependencies commandDependencies) throws Exception {
final int concurrency = Objects.requireNonNull(namespace.getInt(CONCURRENCY_ARGUMENT));
final boolean dryRun = Objects.requireNonNull(namespace.getBoolean(DRY_RUN_ARGUMENT));
final Duration orphanAgeMinimum =
Duration.parse(Objects.requireNonNull(namespace.getString(MINIMUM_ORPHAN_AGE_ARGUMENT)));
final Instant olderThan = clock.instant().minus(orphanAgeMinimum);
logger.info("Crawling preKey page store with concurrency={}, processors={}, dryRun={}. Removing orphans written before={}",
concurrency,
Runtime.getRuntime().availableProcessors(),
dryRun,
olderThan);
final KeysManager keysManager = commandDependencies.keysManager();
final int deletedPages = keysManager.listStoredKEMPreKeyPages(concurrency)
.flatMap(storedPages -> Flux.fromStream(getDetetablePages(storedPages, olderThan))
.concatMap(pageId -> dryRun
? Mono.just(0)
: Mono.fromCompletionStage(() ->
keysManager.pruneDeadPage(storedPages.identifier(), storedPages.deviceId(), pageId))
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))
.thenReturn(1)), concurrency)
.reduce(0, Integer::sum)
.block();
logger.info("Deleted {} orphaned pages", deletedPages);
}
private static Stream<UUID> getDetetablePages(final DeviceKEMPreKeyPages storedPages, final Instant olderThan) {
return storedPages.pageIdToLastModified()
.entrySet()
.stream()
.filter(page -> {
final UUID pageId = page.getKey();
final Instant lastModified = page.getValue();
return shouldDeletePage(storedPages.currentPage(), pageId, olderThan, lastModified);
})
.map(Map.Entry::getKey);
}
@VisibleForTesting
static boolean shouldDeletePage(
final Optional<UUID> currentPage, final UUID page,
final Instant deleteBefore, final Instant lastModified) {
final boolean isCurrentPageForDevice = currentPage.map(uuid -> uuid.equals(page)).orElse(false);
final boolean isStale = lastModified.isBefore(deleteBefore);
Metrics.counter(PAGE_CONSIDERED_COUNTER_NAME,
"isCurrentPageForDevice", Boolean.toString(isCurrentPageForDevice),
"stale", Boolean.toString(isStale))
.increment();
return !isCurrentPageForDevice && isStale;
}
}

View File

@ -10,6 +10,8 @@ option java_package = "org.signal.keytransparency.client";
package kt_query;
import "org/signal/chat/require.proto";
/**
* An external-facing, read-only key transparency service used by Signal's chat server
* to look up and monitor identifiers.
@ -19,8 +21,13 @@ package kt_query;
* - A username hash which also maps to an ACI
* Separately, the log also stores and periodically updates a fixed value known as the `distinguished` key.
* Clients use the verified tree head from looking up this key for future calls to the Search and Monitor endpoints.
*
* Note that this service definition is used in two different contexts:
* 1. Implementing the endpoints with rate-limiting and request validation
* 2. Using the generated client stub to forward requests to the remote key transparency service
*/
service KeyTransparencyQueryService {
option (org.signal.chat.require.auth) = AUTH_ONLY_ANONYMOUS;
/**
* An endpoint used by clients to retrieve the most recent distinguished tree
* head, which should be used to derive consistency parameters for
@ -44,15 +51,15 @@ message SearchRequest {
/**
* The ACI to look up in the log.
*/
bytes aci = 1;
bytes aci = 1 [(org.signal.chat.require.exactlySize) = 16];
/**
* The ACI identity key that the client thinks the ACI maps to in the log.
*/
bytes aci_identity_key = 2;
bytes aci_identity_key = 2 [(org.signal.chat.require.nonEmpty) = true];
/**
* The username hash to look up in the log.
*/
optional bytes username_hash = 3;
optional bytes username_hash = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
/**
* The E164 to look up in the log along with associated data.
*/
@ -60,7 +67,7 @@ message SearchRequest {
/**
* The tree head size(s) to prove consistency against.
*/
ConsistencyParameters consistency = 5;
ConsistencyParameters consistency = 5 [(org.signal.chat.require.present) = true];
}
/**
@ -70,7 +77,7 @@ message E164SearchRequest {
/**
* The E164 that the client wishes to look up in the transparency log.
*/
string e164 = 1;
optional string e164 = 1 [(org.signal.chat.require.e164) = true];
/**
* The unidentified access key of the account associated with the provided E164.
*/
@ -328,28 +335,28 @@ message PrefixSearchResult {
}
message MonitorRequest {
AciMonitorRequest aci = 1;
AciMonitorRequest aci = 1 [(org.signal.chat.require.present) = true];
optional UsernameHashMonitorRequest username_hash = 2;
optional E164MonitorRequest e164 = 3;
ConsistencyParameters consistency = 4;
ConsistencyParameters consistency = 4 [(org.signal.chat.require.present) = true];
}
message AciMonitorRequest {
bytes aci = 1;
bytes aci = 1 [(org.signal.chat.require.exactlySize) = 16];
uint64 entry_position = 2;
bytes commitment_index = 3;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
}
message UsernameHashMonitorRequest {
bytes username_hash = 1;
bytes username_hash = 1 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
uint64 entry_position = 2;
bytes commitment_index = 3;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
}
message E164MonitorRequest {
string e164 = 1;
optional string e164 = 1 [(org.signal.chat.require.e164) = true];
uint64 entry_position = 2;
bytes commitment_index = 3;
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
}
message MonitorProof {

View File

@ -35,7 +35,11 @@ message Envelope {
optional bool story = 16; // indicates that the content is a story.
optional bytes report_spam_token = 17; // token sent when reporting spam
optional bytes shared_mrm_key = 18; // indicates content should be fetched from multi-recipient message datastore
// next: 19
optional bytes source_service_id_binary = 19; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
optional bytes destination_service_id_binary = 20; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)
optional bytes server_guid_binary = 21; // 16-byte UUID
optional bytes updated_pni_binary = 22; // 16-byte UUID
// next: 22
}
message ProvisioningAddress {

View File

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

View File

@ -21,6 +21,7 @@ import jakarta.ws.rs.core.MediaType;
import java.io.IOException;
import java.net.URI;
import java.util.EnumSet;
import java.util.Optional;
import org.apache.commons.lang3.RandomStringUtils;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
@ -34,9 +35,7 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;
import org.whispersystems.textsecuregcm.tests.util.TestWebsocketListener;
import org.whispersystems.websocket.ReusableAuth;
import org.whispersystems.websocket.WebSocketResourceProviderFactory;
import org.whispersystems.websocket.auth.PrincipalSupplier;
import org.whispersystems.websocket.configuration.WebSocketConfiguration;
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
import org.whispersystems.websocket.setup.WebSocketEnvironment;
@ -78,8 +77,7 @@ public class WebsocketResourceProviderIntegrationTest {
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
webSocketEnvironment.jersey().register(testController);
webSocketEnvironment.jersey().register(new RemoteAddressFilter());
webSocketEnvironment.setAuthenticator(upgradeRequest ->
ReusableAuth.authenticated(mock(AuthenticatedDevice.class), PrincipalSupplier.forImmutablePrincipal()));
webSocketEnvironment.setAuthenticator(upgradeRequest -> Optional.of(mock(AuthenticatedDevice.class)));
webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
webSocketEnvironment.setConnectListener(webSocketSessionContext -> {

View File

@ -1,279 +0,0 @@
package org.whispersystems.textsecuregcm;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static org.whispersystems.textsecuregcm.filters.RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME;
import io.dropwizard.auth.Auth;
import io.dropwizard.core.Application;
import io.dropwizard.core.Configuration;
import io.dropwizard.core.setup.Environment;
import io.dropwizard.testing.junit5.DropwizardAppExtension;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.ServletRegistration;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import java.io.IOException;
import java.net.URI;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.IntStream;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
import org.glassfish.jersey.server.ManagedAsync;
import org.glassfish.jersey.server.ServerProperties;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;
import org.whispersystems.textsecuregcm.storage.RefreshingAccountNotFoundException;
import org.whispersystems.textsecuregcm.tests.util.TestWebsocketListener;
import org.whispersystems.websocket.ReusableAuth;
import org.whispersystems.websocket.WebSocketResourceProviderFactory;
import org.whispersystems.websocket.auth.PrincipalSupplier;
import org.whispersystems.websocket.auth.ReadOnly;
import org.whispersystems.websocket.configuration.WebSocketConfiguration;
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
import org.whispersystems.websocket.setup.WebSocketEnvironment;
@ExtendWith(DropwizardExtensionsSupport.class)
public class WebsocketReuseAuthIntegrationTest {
private static final AuthenticatedDevice ACCOUNT = mock(AuthenticatedDevice.class);
@SuppressWarnings("unchecked")
private static final PrincipalSupplier<AuthenticatedDevice> PRINCIPAL_SUPPLIER = mock(PrincipalSupplier.class);
private static final DropwizardAppExtension<Configuration> DROPWIZARD_APP_EXTENSION =
new DropwizardAppExtension<>(TestApplication.class);
private WebSocketClient client;
@BeforeEach
void setUp() throws Exception {
reset(PRINCIPAL_SUPPLIER);
reset(ACCOUNT);
when(ACCOUNT.getName()).thenReturn("original");
client = new WebSocketClient();
client.start();
}
@AfterEach
void tearDown() throws Exception {
client.stop();
}
public static class TestApplication extends Application<Configuration> {
@Override
public void run(final Configuration configuration, final Environment environment) throws Exception {
final TestController testController = new TestController();
final WebSocketConfiguration webSocketConfiguration = new WebSocketConfiguration();
final WebSocketEnvironment<AuthenticatedDevice> webSocketEnvironment =
new WebSocketEnvironment<>(environment, webSocketConfiguration);
environment.jersey().register(testController);
environment.servlets()
.addFilter("RemoteAddressFilter", new RemoteAddressFilter())
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
webSocketEnvironment.jersey().register(testController);
webSocketEnvironment.jersey().register(new RemoteAddressFilter());
webSocketEnvironment.setAuthenticator(upgradeRequest -> ReusableAuth.authenticated(ACCOUNT, PRINCIPAL_SUPPLIER));
webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
webSocketEnvironment.setConnectListener(webSocketSessionContext -> {
});
final WebSocketResourceProviderFactory<AuthenticatedDevice> webSocketServlet =
new WebSocketResourceProviderFactory<>(webSocketEnvironment, AuthenticatedDevice.class,
webSocketConfiguration, REMOTE_ADDRESS_ATTRIBUTE_NAME);
JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), null);
final ServletRegistration.Dynamic websocketServlet =
environment.servlets().addServlet("WebSocket", webSocketServlet);
websocketServlet.addMapping("/websocket");
websocketServlet.setAsyncSupported(true);
}
}
private WebSocketResponseMessage make1WebsocketRequest(final String requestPath) throws IOException {
final TestWebsocketListener testWebsocketListener = new TestWebsocketListener();
client.connect(testWebsocketListener,
URI.create(String.format("ws://127.0.0.1:%d/websocket", DROPWIZARD_APP_EXTENSION.getLocalPort())));
return testWebsocketListener.doGet(requestPath).join();
}
@ParameterizedTest
@ValueSource(strings = {"/test/read-auth", "/test/optional-read-auth"})
public void readAuth(final String path) throws IOException {
final WebSocketResponseMessage response = make1WebsocketRequest(path);
assertThat(response.getStatus()).isEqualTo(200);
verifyNoMoreInteractions(PRINCIPAL_SUPPLIER);
}
@ParameterizedTest
@ValueSource(strings = {"/test/write-auth", "/test/optional-write-auth"})
public void writeAuth(final String path) throws IOException {
final AuthenticatedDevice copiedAccount = mock(AuthenticatedDevice.class);
when(copiedAccount.getName()).thenReturn("copy");
when(PRINCIPAL_SUPPLIER.deepCopy(any())).thenReturn(copiedAccount);
final WebSocketResponseMessage response = make1WebsocketRequest(path);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getBody().map(String::new)).get().isEqualTo("copy");
verify(PRINCIPAL_SUPPLIER, times(1)).deepCopy(any());
verifyNoMoreInteractions(PRINCIPAL_SUPPLIER);
}
@Test
public void readAfterWrite() throws IOException {
when(PRINCIPAL_SUPPLIER.deepCopy(any())).thenReturn(ACCOUNT);
final AuthenticatedDevice account2 = mock(AuthenticatedDevice.class);
when(account2.getName()).thenReturn("refresh");
when(PRINCIPAL_SUPPLIER.refresh(any())).thenReturn(account2);
final TestWebsocketListener testWebsocketListener = new TestWebsocketListener();
client.connect(testWebsocketListener,
URI.create(String.format("ws://127.0.0.1:%d/websocket", DROPWIZARD_APP_EXTENSION.getLocalPort())));
final WebSocketResponseMessage readResponse = testWebsocketListener.doGet("/test/read-auth").join();
assertThat(readResponse.getBody().map(String::new)).get().isEqualTo("original");
final WebSocketResponseMessage writeResponse = testWebsocketListener.doGet("/test/write-auth").join();
assertThat(writeResponse.getBody().map(String::new)).get().isEqualTo("original");
final WebSocketResponseMessage readResponse2 = testWebsocketListener.doGet("/test/read-auth").join();
assertThat(readResponse2.getBody().map(String::new)).get().isEqualTo("refresh");
}
@Test
public void readAfterWriteRefreshFails() throws IOException {
when(PRINCIPAL_SUPPLIER.deepCopy(any())).thenReturn(ACCOUNT);
when(PRINCIPAL_SUPPLIER.refresh(any())).thenThrow(RefreshingAccountNotFoundException.class);
final TestWebsocketListener testWebsocketListener = new TestWebsocketListener();
client.connect(testWebsocketListener,
URI.create(String.format("ws://127.0.0.1:%d/websocket", DROPWIZARD_APP_EXTENSION.getLocalPort())));
final WebSocketResponseMessage writeResponse = testWebsocketListener.doGet("/test/write-auth").join();
assertThat(writeResponse.getBody().map(String::new)).get().isEqualTo("original");
final WebSocketResponseMessage readResponse2 = testWebsocketListener.doGet("/test/read-auth").join();
assertThat(readResponse2.getStatus()).isEqualTo(500);
}
@Test
public void readConcurrentWithWrite() throws IOException, ExecutionException, InterruptedException, TimeoutException {
final AuthenticatedDevice deepCopy = mock(AuthenticatedDevice.class);
when(deepCopy.getName()).thenReturn("deepCopy");
when(PRINCIPAL_SUPPLIER.deepCopy(any())).thenReturn(deepCopy);
final AuthenticatedDevice refresh = mock(AuthenticatedDevice.class);
when(refresh.getName()).thenReturn("refresh");
when(PRINCIPAL_SUPPLIER.refresh(any())).thenReturn(refresh);
final TestWebsocketListener testWebsocketListener = new TestWebsocketListener();
client.connect(testWebsocketListener,
URI.create(String.format("ws://127.0.0.1:%d/websocket", DROPWIZARD_APP_EXTENSION.getLocalPort())));
// start a write request that takes a while to finish
final CompletableFuture<WebSocketResponseMessage> writeResponse =
testWebsocketListener.doGet("/test/start-delayed-write/foo");
// send a bunch of reads, they should reflect the original auth
final List<CompletableFuture<WebSocketResponseMessage>> futures = IntStream.range(0, 10)
.boxed().map(i -> testWebsocketListener.doGet("/test/read-auth"))
.toList();
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
for (CompletableFuture<WebSocketResponseMessage> future : futures) {
assertThat(future.join().getBody().map(String::new)).get().isEqualTo("original");
}
assertThat(writeResponse.isDone()).isFalse();
// finish the delayed write request
testWebsocketListener.doGet("/test/finish-delayed-write/foo").get(1, TimeUnit.SECONDS);
assertThat(writeResponse.join().getBody().map(String::new)).get().isEqualTo("deepCopy");
// subsequent reads should have the refreshed auth
final WebSocketResponseMessage readResponse = testWebsocketListener.doGet("/test/read-auth").join();
assertThat(readResponse.getBody().map(String::new)).get().isEqualTo("refresh");
}
@Path("/test")
public static class TestController {
private final ConcurrentHashMap<String, CountDownLatch> delayedWriteLatches = new ConcurrentHashMap<>();
@GET
@Path("/read-auth")
@ManagedAsync
public String readAuth(@ReadOnly @Auth final AuthenticatedDevice account) {
return account.getName();
}
@GET
@Path("/optional-read-auth")
@ManagedAsync
public String optionalReadAuth(@ReadOnly @Auth final Optional<AuthenticatedDevice> account) {
return account.map(AuthenticatedDevice::getName).orElse("empty");
}
@GET
@Path("/write-auth")
@ManagedAsync
public String writeAuth(@Auth final AuthenticatedDevice account) {
return account.getName();
}
@GET
@Path("/optional-write-auth")
@ManagedAsync
public String optionalWriteAuth(@Auth final Optional<AuthenticatedDevice> account) {
return account.map(AuthenticatedDevice::getName).orElse("empty");
}
@GET
@Path("/start-delayed-write/{id}")
@ManagedAsync
public String startDelayedWrite(@Auth final AuthenticatedDevice account, @PathParam("id") String id)
throws InterruptedException {
delayedWriteLatches.computeIfAbsent(id, i -> new CountDownLatch(1)).await();
return account.getName();
}
@GET
@Path("/finish-delayed-write/{id}")
@ManagedAsync
public String finishDelayedWrite(@PathParam("id") String id) {
delayedWriteLatches.computeIfAbsent(id, i -> new CountDownLatch(1)).countDown();
return "ok";
}
}
}

Some files were not shown because too many files have changed in this diff Show More