Compare commits
130 Commits
v20250409.
...
main
Author | SHA1 | Date |
---|---|---|
![]() |
f4698dd5b2 | |
![]() |
d4322a2ed4 | |
![]() |
7260a9d5b4 | |
![]() |
12b4ceb4aa | |
![]() |
fa1cd5c263 | |
![]() |
f8da13912d | |
![]() |
a3b3bf86ba | |
![]() |
a99f7bb87d | |
![]() |
d6f14d02dd | |
![]() |
d18671eaf9 | |
![]() |
87c30d00e8 | |
![]() |
c8f45685b8 | |
![]() |
bb90d80d22 | |
![]() |
dcc541f86e | |
![]() |
aaa36fd8f5 | |
![]() |
2bb14892af | |
![]() |
6d8701665e | |
![]() |
c2b8fdac0d | |
![]() |
059caa4c57 | |
![]() |
51773f5709 | |
![]() |
483404a67f | |
![]() |
68b84dd56b | |
![]() |
7709e1313c | |
![]() |
c952baa672 | |
![]() |
9dfe51eac4 | |
![]() |
5de848bf38 | |
![]() |
295cedc075 | |
![]() |
4f1cab407f | |
![]() |
626a7fdad7 | |
![]() |
9a1da23bdb | |
![]() |
4ffd164461 | |
![]() |
904cc63a72 | |
![]() |
177c36b0d6 | |
![]() |
5fc6bdd478 | |
![]() |
ca6e5fb0a8 | |
![]() |
1a7a446150 | |
![]() |
981d929f50 | |
![]() |
4a3eb642c0 | |
![]() |
a1b0c1a4aa | |
![]() |
0f185a528d | |
![]() |
aef7f3fef8 | |
![]() |
1767586797 | |
![]() |
60be6de9af | |
![]() |
2a7551cca5 | |
![]() |
36439b5252 | |
![]() |
bbee80dbd0 | |
![]() |
a7ea42adc3 | |
![]() |
4dc3b19d2a | |
![]() |
030d8e8dd4 | |
![]() |
401165d0d6 | |
![]() |
ccb209ad37 | |
![]() |
c1a66e0418 | |
![]() |
8491d18413 | |
![]() |
9b835633ab | |
![]() |
fbbc4b8b27 | |
![]() |
74ee1c8c4f | |
![]() |
35604cf151 | |
![]() |
aafcd63a9f | |
![]() |
43a534f05b | |
![]() |
9ec66dac7f | |
![]() |
13fc0ffbca | |
![]() |
93ba6616d1 | |
![]() |
a4b98f38a6 | |
![]() |
b95d08aaea | |
![]() |
b400d49e77 | |
![]() |
e43487155f | |
![]() |
dee3723d97 | |
![]() |
b7e986f43c | |
![]() |
664fb23e97 | |
![]() |
714ef128a1 | |
![]() |
7cf3fce624 | |
![]() |
0cc5431867 | |
![]() |
b8d5b2c8ea | |
![]() |
894ca6d290 | |
![]() |
847b25f695 | |
![]() |
703a05cb15 | |
![]() |
30c194c557 | |
![]() |
cc7b030a41 | |
![]() |
7a91c4d5b7 | |
![]() |
287da6e7e3 | |
![]() |
7cf89764e7 | |
![]() |
d316c72beb | |
![]() |
82d187cc45 | |
![]() |
0c240d21d2 | |
![]() |
009252c831 | |
![]() |
0c1146aaa5 | |
![]() |
4fd06594a0 | |
![]() |
4e175be88f | |
![]() |
771a700acd | |
![]() |
e9bd5da2c3 | |
![]() |
f64244f33a | |
![]() |
ed1417c3e3 | |
![]() |
0398e02690 | |
![]() |
e285bf1a52 | |
![]() |
2c9219d4f7 | |
![]() |
26b3b75054 | |
![]() |
cdb651b68f | |
![]() |
91a36f4421 | |
![]() |
21c1d71551 | |
![]() |
38befdb260 | |
![]() |
63c79173b2 | |
![]() |
d2ad003891 | |
![]() |
eb89773819 | |
![]() |
403abd84f6 | |
![]() |
f62f79c95c | |
![]() |
144c4c9223 | |
![]() |
ab4fc4f459 | |
![]() |
51569ce0a5 | |
![]() |
f191c68efc | |
![]() |
bb8ce6d981 | |
![]() |
e0ee75e0d0 | |
![]() |
1ef3a230a1 | |
![]() |
b1805d4bf1 | |
![]() |
cac979c7fd | |
![]() |
4072dcdda5 | |
![]() |
ed382fff6d | |
![]() |
23bb8277d5 | |
![]() |
8099d6465c | |
![]() |
28a0b9e84e | |
![]() |
9287aaf7ce | |
![]() |
0585f862cb | |
![]() |
7cac6f6f72 | |
![]() |
57be4d798b | |
![]() |
05c74f1997 | |
![]() |
f5e49b6db7 | |
![]() |
3c40e72d27 | |
![]() |
2f2ae7cec5 | |
![]() |
b236b53dc3 | |
![]() |
eb71e30046 | |
![]() |
aa5fd52302 |
|
@ -5,30 +5,33 @@ on:
|
|||
- cron: '30 19 * * MON-FRI'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# This may seem a little redundant, but copying the configuration to an environment variable makes it easier and safer
|
||||
# to then write its contents to a file
|
||||
INTEGRATION_TEST_CONFIG: ${{ vars.INTEGRATION_TEST_CONFIG }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ vars.INTEGRATION_TESTS_BUCKET != '' }}
|
||||
if: ${{ vars.INTEGRATION_TEST_CONFIG != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
cache: 'maven'
|
||||
- uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0
|
||||
- uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1
|
||||
name: Configure AWS credentials from Test account
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Fetch integration utils library
|
||||
- name: Write integration test configuration
|
||||
run: |
|
||||
mkdir -p integration-tests/.libs
|
||||
mkdir -p integration-tests/src/main/resources
|
||||
wget -O integration-tests/.libs/software.amazon.awssdk-sso.jar https://repo1.maven.org/maven2/software/amazon/awssdk/sso/2.19.8/sso-2.19.8.jar
|
||||
aws s3 cp "s3://${{ vars.INTEGRATION_TESTS_BUCKET }}/config-latest.yml" integration-tests/src/main/resources/config.yml
|
||||
echo "${INTEGRATION_TEST_CONFIG}" > integration-tests/src/main/resources/config.yml
|
||||
- name: Run and verify integration tests
|
||||
run: ./mvnw clean compile test-compile failsafe:integration-test failsafe:verify
|
||||
run: ./mvnw clean compile test-compile failsafe:integration-test failsafe:verify -P aws-sso
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
name: Service CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- gh-pages
|
||||
|
@ -11,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
|
||||
|
@ -25,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
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
|
|
@ -57,4 +57,17 @@
|
|||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>aws-sso</id>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>sso</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
|
|
|
@ -72,14 +72,12 @@ public final class Operations {
|
|||
}
|
||||
|
||||
public static TestUser newRegisteredUser(final String number) {
|
||||
final byte[] registrationPassword = randomBytes(32);
|
||||
final byte[] registrationPassword = populateRandomRecoveryPassword(number);
|
||||
final String accountPassword = Base64.getEncoder().encodeToString(randomBytes(32));
|
||||
|
||||
final TestUser user = TestUser.create(number, accountPassword, registrationPassword);
|
||||
final AccountAttributes accountAttributes = user.accountAttributes();
|
||||
|
||||
INTEGRATION_TOOLS.populateRecoveryPassword(number, registrationPassword).join();
|
||||
|
||||
final ECKeyPair aciIdentityKeyPair = Curve.generateKeyPair();
|
||||
final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair();
|
||||
|
||||
|
@ -108,6 +106,7 @@ public final class Operations {
|
|||
}
|
||||
|
||||
public record PrescribedVerificationNumber(String number, String verificationCode) {}
|
||||
|
||||
public static PrescribedVerificationNumber prescribedVerificationNumber() {
|
||||
return new PrescribedVerificationNumber(
|
||||
CONFIG.prescribedRegistrationNumber(),
|
||||
|
@ -123,6 +122,13 @@ public final class Operations {
|
|||
.orElseThrow(() -> new RuntimeException("push challenge not found for the verification session"));
|
||||
}
|
||||
|
||||
public static byte[] populateRandomRecoveryPassword(final String number) {
|
||||
final byte[] recoveryPassword = randomBytes(32);
|
||||
INTEGRATION_TOOLS.populateRecoveryPassword(number, recoveryPassword).join();
|
||||
|
||||
return recoveryPassword;
|
||||
}
|
||||
|
||||
public static <T> T sendEmptyRequestAuthenticated(
|
||||
final String endpoint,
|
||||
final String method,
|
||||
|
@ -329,15 +335,15 @@ public final class Operations {
|
|||
}
|
||||
}
|
||||
|
||||
private static ECSignedPreKey generateSignedECPreKey(long id, final ECKeyPair identityKeyPair) {
|
||||
public static ECSignedPreKey generateSignedECPreKey(final long id, final ECKeyPair identityKeyPair) {
|
||||
final ECPublicKey pubKey = Curve.generateKeyPair().getPublicKey();
|
||||
final byte[] sig = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());
|
||||
return new ECSignedPreKey(id, pubKey, sig);
|
||||
final byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());
|
||||
return new ECSignedPreKey(id, pubKey, signature);
|
||||
}
|
||||
|
||||
private static KEMSignedPreKey generateSignedKEMPreKey(long id, final ECKeyPair identityKeyPair) {
|
||||
public static KEMSignedPreKey generateSignedKEMPreKey(final long id, final ECKeyPair identityKeyPair) {
|
||||
final KEMPublicKey pubKey = KEMKeyPair.generate(KEMKeyType.KYBER_1024).getPublicKey();
|
||||
final byte[] sig = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());
|
||||
return new KEMSignedPreKey(id, pubKey, sig);
|
||||
final byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());
|
||||
return new KEMSignedPreKey(id, pubKey, signature);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,27 +6,35 @@
|
|||
package org.signal.integration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
public class AccountTest {
|
||||
|
||||
@Test
|
||||
public void testCreateAccount() throws Exception {
|
||||
public void testCreateAccount() {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550101");
|
||||
try {
|
||||
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
|
||||
|
@ -39,7 +47,7 @@ public class AccountTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testCreateAccountAtomic() throws Exception {
|
||||
public void testCreateAccountAtomic() {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550201");
|
||||
try {
|
||||
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
|
||||
|
@ -51,6 +59,33 @@ public class AccountTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changePhoneNumber() {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550301");
|
||||
final String targetNumber = "+19995550302";
|
||||
|
||||
final ECKeyPair pniIdentityKeyPair = Curve.generateKeyPair();
|
||||
|
||||
final ChangeNumberRequest changeNumberRequest = new ChangeNumberRequest(null,
|
||||
Operations.populateRandomRecoveryPassword(targetNumber),
|
||||
targetNumber,
|
||||
null,
|
||||
new IdentityKey(pniIdentityKeyPair.getPublicKey()),
|
||||
Collections.emptyList(),
|
||||
Map.of(Device.PRIMARY_ID, Operations.generateSignedECPreKey(1, pniIdentityKeyPair)),
|
||||
Map.of(Device.PRIMARY_ID, Operations.generateSignedKEMPreKey(2, pniIdentityKeyPair)),
|
||||
Map.of(Device.PRIMARY_ID, 17));
|
||||
|
||||
final AccountIdentityResponse accountIdentityResponse =
|
||||
Operations.apiPut("/v2/accounts/number", changeNumberRequest)
|
||||
.authorized(user)
|
||||
.executeExpectSuccess(AccountIdentityResponse.class);
|
||||
|
||||
assertEquals(user.aciUuid(), accountIdentityResponse.uuid());
|
||||
assertNotEquals(user.pniUuid(), accountIdentityResponse.pni());
|
||||
assertEquals(targetNumber, accountIdentityResponse.number());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUsernameOperations() throws Exception {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550102");
|
||||
|
|
46
pom.xml
46
pom.xml
|
@ -45,8 +45,15 @@
|
|||
<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>
|
||||
<!-- 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.57.0</google-cloud-libraries.version>
|
||||
<grpc.version>1.69.0</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->
|
||||
<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>
|
||||
<!-- several libraries (AWS, Google Cloud) use Apache http components transitively, and we need to align them -->
|
||||
<httpcore.version>4.4.16</httpcore.version>
|
||||
|
@ -65,9 +72,9 @@
|
|||
<luajava.version>3.5.0</luajava.version>
|
||||
<micrometer.version>1.14.5</micrometer.version>
|
||||
<netty.version>4.1.119.Final</netty.version>
|
||||
<!-- Must be greater than or equal to the value from Google libraries-bom
|
||||
since some of its libraries generate code. See https://protobuf.dev/support/cross-version-runtime-guarantee/. -->
|
||||
<protobuf.version>3.25.5</protobuf.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 -->
|
||||
|
@ -77,6 +84,10 @@
|
|||
<slf4j.version>2.0.17</slf4j.version>
|
||||
<stripe.version>23.10.0</stripe.version>
|
||||
<swagger.version>2.2.27</swagger.version>
|
||||
<testcontainers.version>1.21.1</testcontainers.version>
|
||||
|
||||
<!-- 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>
|
||||
|
@ -127,7 +138,7 @@
|
|||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>libraries-bom-protobuf3</artifactId>
|
||||
<artifactId>libraries-bom</artifactId>
|
||||
<version>${google-cloud-libraries.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
|
@ -175,11 +186,6 @@
|
|||
<artifactId>pushy-dropwizard-metrics-listener</artifactId>
|
||||
<version>${pushy.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.protobuf</groupId>
|
||||
<artifactId>protobuf-java</artifactId>
|
||||
<version>${protobuf.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.googlecode.libphonenumber</groupId>
|
||||
<artifactId>libphonenumber</artifactId>
|
||||
|
@ -215,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>
|
||||
|
@ -316,6 +327,19 @@
|
|||
<artifactId>logback-access-common</artifactId>
|
||||
<version>${logback-access.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>
|
||||
|
||||
|
@ -443,7 +467,7 @@
|
|||
<version>0.6.1</version>
|
||||
<configuration>
|
||||
<checkStaleness>false</checkStaleness>
|
||||
<protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
|
||||
<protocArtifact>com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier}</protocArtifact>
|
||||
<pluginId>grpc-java</pluginId>
|
||||
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
|
||||
|
||||
|
|
|
@ -66,7 +66,6 @@ cdn.accessSecret: test # AWS Access Secret
|
|||
|
||||
cdn3StorageManager.clientSecret: test
|
||||
|
||||
unidentifiedDelivery.certificate: ABCD1234
|
||||
unidentifiedDelivery.privateKey: ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AAAAAAA
|
||||
|
||||
keyTransparencyService.clientPrivateKey: |
|
||||
|
|
|
@ -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/
|
||||
|
||||
|
@ -265,7 +271,7 @@ dogstatsd:
|
|||
host: 127.0.0.1
|
||||
|
||||
unidentifiedDelivery:
|
||||
certificate: secret://unidentifiedDelivery.certificate
|
||||
certificate: CgIIAQ==
|
||||
privateKey: secret://unidentifiedDelivery.privateKey
|
||||
expiresDays: 7
|
||||
|
||||
|
@ -482,7 +488,8 @@ turn:
|
|||
- turn:%s
|
||||
- turn:%s:80?transport=tcp
|
||||
- turns:%s:443?transport=tcp
|
||||
ttl: 86400
|
||||
requestedCredentialTtl: PT24H
|
||||
clientCredentialTtl: PT12H
|
||||
hostname: turn.cloudflare.example.com
|
||||
numHttpClients: 1
|
||||
|
||||
|
@ -490,7 +497,8 @@ linkDevice:
|
|||
secret: secret://linkDevice.secret
|
||||
|
||||
noiseTunnel:
|
||||
port: 8443
|
||||
webSocketPort: 8444
|
||||
directPort: 8445
|
||||
tlsKeyStoreFile: /path/to/file.p12
|
||||
tlsKeyStoreEntryAlias: example.com
|
||||
tlsKeyStorePassword: secret://noiseTunnel.tlsKeyStorePassword
|
||||
|
|
|
@ -351,6 +351,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 +490,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 +543,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 +686,9 @@
|
|||
<includes>*.yml</includes>
|
||||
<into>/usr/share/signal/</into>
|
||||
</path>
|
||||
<path>
|
||||
<from>${project.build.directory}/jib-extra</from>
|
||||
</path>
|
||||
</paths>
|
||||
</extraDirectories>
|
||||
</configuration>
|
||||
|
@ -712,6 +760,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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -43,8 +43,9 @@ import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfigurat
|
|||
import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalityEstimatorConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.NoiseWebSocketTunnelConfiguration;
|
||||
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;
|
||||
|
@ -257,6 +258,11 @@ public class WhisperServerConfiguration extends Configuration {
|
|||
@NotNull
|
||||
private OneTimeDonationConfiguration oneTimeDonations;
|
||||
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private PagedSingleUseKEMPreKeyStoreConfiguration pagedSingleUseKEMPreKeyStore;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
|
@ -304,7 +310,7 @@ public class WhisperServerConfiguration extends Configuration {
|
|||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private NoiseWebSocketTunnelConfiguration noiseTunnel;
|
||||
private NoiseTunnelConfiguration noiseTunnel;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
|
@ -407,10 +413,6 @@ public class WhisperServerConfiguration extends Configuration {
|
|||
return rateLimitersCluster;
|
||||
}
|
||||
|
||||
public Map<String, RateLimiterConfig> getLimitsConfiguration() {
|
||||
return limits;
|
||||
}
|
||||
|
||||
public FcmConfiguration getFcmConfiguration() {
|
||||
return fcm;
|
||||
}
|
||||
|
@ -482,6 +484,10 @@ public class WhisperServerConfiguration extends Configuration {
|
|||
return oneTimeDonations;
|
||||
}
|
||||
|
||||
public PagedSingleUseKEMPreKeyStoreConfiguration getPagedSingleUseKEMPreKeyStore() {
|
||||
return pagedSingleUseKEMPreKeyStore;
|
||||
}
|
||||
|
||||
public ReportMessageConfiguration getReportMessageConfiguration() {
|
||||
return reportMessage;
|
||||
}
|
||||
|
@ -518,7 +524,7 @@ public class WhisperServerConfiguration extends Configuration {
|
|||
return virtualThread;
|
||||
}
|
||||
|
||||
public NoiseWebSocketTunnelConfiguration getNoiseWebSocketTunnelConfiguration() {
|
||||
public NoiseTunnelConfiguration getNoiseTunnelConfiguration() {
|
||||
return noiseTunnel;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
@ -154,7 +153,8 @@ import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
|||
import org.whispersystems.textsecuregcm.grpc.net.ManagedDefaultEventLoopGroup;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.ManagedLocalGrpcServer;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.ManagedNioEventLoopGroup;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseWebSocketTunnelServer;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.noisedirect.NoiseDirectTunnelServer;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.websocket.NoiseWebSocketTunnelServer;
|
||||
import org.whispersystems.textsecuregcm.jetty.JettyHttpConfigurationCustomizer;
|
||||
import org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;
|
||||
import org.whispersystems.textsecuregcm.limits.CardinalityEstimator;
|
||||
|
@ -211,7 +211,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;
|
||||
|
@ -226,6 +225,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;
|
||||
|
@ -236,8 +236,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;
|
||||
|
@ -262,6 +266,7 @@ import org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener;
|
|||
import org.whispersystems.textsecuregcm.websocket.ProvisioningConnectListener;
|
||||
import org.whispersystems.textsecuregcm.websocket.WebSocketAccountAuthenticator;
|
||||
import org.whispersystems.textsecuregcm.workers.BackupMetricsCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.BackupUsageRecalculationCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.CertificateCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.CheckDynamicConfigurationCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.DeleteUserCommand;
|
||||
|
@ -269,6 +274,7 @@ import org.whispersystems.textsecuregcm.workers.IdleDeviceNotificationSchedulerF
|
|||
import org.whispersystems.textsecuregcm.workers.MessagePersisterServiceCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.NotifyIdleDevicesCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.ProcessScheduledJobsServiceCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.RegenerateSecondaryDynamoDbTableDataCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.RemoveExpiredBackupsCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.RemoveExpiredLinkedDevicesCommand;
|
||||
|
@ -284,12 +290,10 @@ import org.whispersystems.websocket.setup.WebSocketEnvironment;
|
|||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
import software.amazon.awssdk.http.crt.AwsCrtHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
public class WhisperServerService extends Application<WhisperServerConfiguration> {
|
||||
|
||||
|
@ -329,12 +333,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
bootstrap.addCommand(new RemoveExpiredUsernameHoldsCommand(Clock.systemUTC()));
|
||||
bootstrap.addCommand(new RemoveExpiredBackupsCommand(Clock.systemUTC()));
|
||||
bootstrap.addCommand(new BackupMetricsCommand(Clock.systemUTC()));
|
||||
bootstrap.addCommand(new BackupUsageRecalculationCommand());
|
||||
bootstrap.addCommand(new RemoveExpiredLinkedDevicesCommand());
|
||||
bootstrap.addCommand(new NotifyIdleDevicesCommand());
|
||||
|
||||
bootstrap.addCommand(new ProcessScheduledJobsServiceCommand("process-idle-device-notification-jobs",
|
||||
"Processes scheduled jobs to send notifications to idle devices",
|
||||
new IdleDeviceNotificationSchedulerFactory()));
|
||||
|
||||
bootstrap.addCommand(new RegenerateSecondaryDynamoDbTableDataCommand());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -391,6 +398,12 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
final DynamoDbClient dynamoDbClient = config.getDynamoDbClientConfiguration()
|
||||
.buildSyncClient(awsCredentialsProvider, new MicrometerAwsSdkMetricPublisher(awsSdkMetricsExecutor, "dynamoDbSync"));
|
||||
|
||||
final AwsCredentialsProvider cdnCredentialsProvider = config.getCdnConfiguration().credentials().build();
|
||||
final S3AsyncClient asyncCdnS3Client = S3AsyncClient.builder()
|
||||
.credentialsProvider(cdnCredentialsProvider)
|
||||
.region(Region.of(config.getCdnConfiguration().region()))
|
||||
.build();
|
||||
|
||||
BlockingQueue<Runnable> messageDeletionQueue = new LinkedBlockingQueue<>();
|
||||
Metrics.gaugeCollectionSize(name(getClass(), "messageDeletionQueueSize"), Collections.emptyList(),
|
||||
messageDeletionQueue);
|
||||
|
@ -417,13 +430,21 @@ 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(
|
||||
new SingleUseECPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getEcKeys().getTableName()),
|
||||
new SingleUseKEMPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getKemKeys().getTableName()),
|
||||
new PagedSingleUseKEMPreKeyStore(
|
||||
dynamoDbAsyncClient,
|
||||
config.getDynamoDbTables().getEcKeys().getTableName(),
|
||||
config.getDynamoDbTables().getKemKeys().getTableName(),
|
||||
config.getDynamoDbTables().getEcSignedPreKeys().getTableName(),
|
||||
config.getDynamoDbTables().getKemLastResortKeys().getTableName()
|
||||
);
|
||||
asyncKeysS3Client,
|
||||
config.getDynamoDbTables().getPagedKemKeys().getTableName(),
|
||||
config.getPagedSingleUseKEMPreKeyStore().bucket()),
|
||||
new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getEcSignedPreKeys().getTableName()),
|
||||
new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getKemLastResortKeys().getTableName()));
|
||||
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
|
||||
config.getDynamoDbTables().getMessages().getTableName(),
|
||||
config.getDynamoDbTables().getMessages().getExpiration(),
|
||||
|
@ -497,8 +518,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
.scheduledExecutorService(name(getClass(), "remoteStorageRetry-%d")).threads(1).build();
|
||||
ScheduledExecutorService registrationIdentityTokenRefreshExecutor = environment.lifecycle()
|
||||
.scheduledExecutorService(name(getClass(), "registrationIdentityTokenRefresh-%d")).threads(1).build();
|
||||
ScheduledExecutorService recurringConfigSyncExecutor = environment.lifecycle()
|
||||
.scheduledExecutorService(name(getClass(), "configSync-%d")).threads(1).build();
|
||||
|
||||
Scheduler messageDeliveryScheduler = Schedulers.fromExecutorService(
|
||||
ExecutorServiceMetrics.monitor(Metrics.globalRegistry,
|
||||
|
@ -548,8 +567,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()
|
||||
|
@ -600,14 +617,13 @@ 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,
|
||||
storageServiceExecutor, storageServiceRetryExecutor, config.getSecureStorageServiceConfiguration());
|
||||
DisconnectionRequestManager disconnectionRequestManager = new DisconnectionRequestManager(pubsubClient, disconnectionRequestListenerExecutor);
|
||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster, asyncCdnS3Client, config.getCdnConfiguration().bucket());
|
||||
MessagesCache messagesCache = new MessagesCache(messagesCluster, messageDeliveryScheduler,
|
||||
messageDeletionAsyncExecutor, clock);
|
||||
ClientReleaseManager clientReleaseManager = new ClientReleaseManager(clientReleases,
|
||||
|
@ -636,8 +652,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
new PushNotificationManager(accountsManager, apnSender, fcmSender, pushNotificationScheduler);
|
||||
WebSocketConnectionEventManager webSocketConnectionEventManager =
|
||||
new WebSocketConnectionEventManager(accountsManager, pushNotificationManager, messagesCluster, clientEventExecutor, asyncOperationQueueingExecutor);
|
||||
RateLimiters rateLimiters = RateLimiters.createAndValidate(config.getLimitsConfiguration(),
|
||||
dynamicConfigurationManager, rateLimitersCluster);
|
||||
RateLimiters rateLimiters = RateLimiters.create(dynamicConfigurationManager, rateLimitersCluster);
|
||||
ProvisioningManager provisioningManager = new ProvisioningManager(pubsubClient);
|
||||
IssuedReceiptsManager issuedReceiptsManager = new IssuedReceiptsManager(
|
||||
config.getDynamoDbTables().getIssuedReceipts().getTableName(),
|
||||
|
@ -668,12 +683,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
|
||||
final AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
|
||||
|
||||
final MessageSender messageSender = new MessageSender(messagesManager, pushNotificationManager);
|
||||
final MessageSender messageSender = new MessageSender(messagesManager, pushNotificationManager, experimentEnrollmentManager);
|
||||
final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
|
||||
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(
|
||||
config.getTurnConfiguration().cloudflare().apiToken().value(),
|
||||
config.getTurnConfiguration().cloudflare().endpoint(),
|
||||
config.getTurnConfiguration().cloudflare().ttl(),
|
||||
config.getTurnConfiguration().cloudflare().requestedCredentialTtl(),
|
||||
config.getTurnConfiguration().cloudflare().clientCredentialTtl(),
|
||||
config.getTurnConfiguration().cloudflare().urls(),
|
||||
config.getTurnConfiguration().cloudflare().urlsWithIps(),
|
||||
config.getTurnConfiguration().cloudflare().hostname(),
|
||||
|
@ -693,7 +709,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager,
|
||||
pushChallengeDynamoDb);
|
||||
|
||||
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
|
||||
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager, Clock.systemUTC());
|
||||
|
||||
HttpClient currencyClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();
|
||||
FixerClient fixerClient = config.getPaymentsServiceConfiguration().externalClients()
|
||||
|
@ -742,18 +758,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
environment.lifecycle().manage(virtualThreadPinEventMonitor);
|
||||
environment.lifecycle().manage(accountsManager);
|
||||
|
||||
|
||||
AwsCredentialsProvider cdnCredentialsProvider = config.getCdnConfiguration().credentials().build();
|
||||
S3Client cdnS3Client = S3Client.builder()
|
||||
.credentialsProvider(cdnCredentialsProvider)
|
||||
.region(Region.of(config.getCdnConfiguration().region()))
|
||||
.httpClientBuilder(AwsCrtHttpClient.builder())
|
||||
.build();
|
||||
S3AsyncClient asyncCdnS3Client = S3AsyncClient.builder()
|
||||
.credentialsProvider(cdnCredentialsProvider)
|
||||
.region(Region.of(config.getCdnConfiguration().region()))
|
||||
.build();
|
||||
|
||||
final GcsAttachmentGenerator gcsAttachmentGenerator = new GcsAttachmentGenerator(
|
||||
config.getGcpAttachmentsConfiguration().domain(),
|
||||
config.getGcpAttachmentsConfiguration().email(),
|
||||
|
@ -871,23 +875,24 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
.addService(ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters))
|
||||
.addService(new KeysGrpcService(accountsManager, keysManager, rateLimiters))
|
||||
.addService(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager,
|
||||
config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters, zkProfileOperations, config.getCdnConfiguration().bucket()));
|
||||
config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters, zkProfileOperations));
|
||||
}
|
||||
};
|
||||
|
||||
@Nullable final X509Certificate[] noiseWebSocketTlsCertificateChain;
|
||||
@Nullable final PrivateKey noiseWebSocketTlsPrivateKey;
|
||||
|
||||
if (config.getNoiseWebSocketTunnelConfiguration().tlsKeyStoreFile() != null &&
|
||||
config.getNoiseWebSocketTunnelConfiguration().tlsKeyStoreEntryAlias() != null &&
|
||||
config.getNoiseWebSocketTunnelConfiguration().tlsKeyStorePassword() != null) {
|
||||
if (config.getNoiseTunnelConfiguration().tlsKeyStoreFile() != null &&
|
||||
config.getNoiseTunnelConfiguration().tlsKeyStoreEntryAlias() != null &&
|
||||
config.getNoiseTunnelConfiguration().tlsKeyStorePassword() != null) {
|
||||
|
||||
try (final FileInputStream websocketNoiseTunnelTlsKeyStoreInputStream = new FileInputStream(config.getNoiseWebSocketTunnelConfiguration().tlsKeyStoreFile())) {
|
||||
try (final FileInputStream websocketNoiseTunnelTlsKeyStoreInputStream = new FileInputStream(config.getNoiseTunnelConfiguration().tlsKeyStoreFile())) {
|
||||
final KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(websocketNoiseTunnelTlsKeyStoreInputStream, config.getNoiseWebSocketTunnelConfiguration().tlsKeyStorePassword().value().toCharArray());
|
||||
keyStore.load(websocketNoiseTunnelTlsKeyStoreInputStream, config.getNoiseTunnelConfiguration().tlsKeyStorePassword().value().toCharArray());
|
||||
|
||||
final KeyStore.PrivateKeyEntry privateKeyEntry = (KeyStore.PrivateKeyEntry) keyStore.getEntry(config.getNoiseWebSocketTunnelConfiguration().tlsKeyStoreEntryAlias(),
|
||||
new KeyStore.PasswordProtection(config.getNoiseWebSocketTunnelConfiguration().tlsKeyStorePassword().value().toCharArray()));
|
||||
final KeyStore.PrivateKeyEntry privateKeyEntry = (KeyStore.PrivateKeyEntry) keyStore.getEntry(
|
||||
config.getNoiseTunnelConfiguration().tlsKeyStoreEntryAlias(),
|
||||
new KeyStore.PasswordProtection(config.getNoiseTunnelConfiguration().tlsKeyStorePassword().value().toCharArray()));
|
||||
|
||||
noiseWebSocketTlsCertificateChain =
|
||||
Arrays.copyOf(privateKeyEntry.getCertificateChain(), privateKeyEntry.getCertificateChain().length, X509Certificate[].class);
|
||||
|
@ -906,27 +911,37 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
.allowCoreThreadTimeOut(false)
|
||||
.build();
|
||||
|
||||
final ManagedNioEventLoopGroup noiseWebSocketEventLoopGroup = new ManagedNioEventLoopGroup();
|
||||
final ManagedNioEventLoopGroup noiseTunnelEventLoopGroup = new ManagedNioEventLoopGroup();
|
||||
|
||||
final NoiseWebSocketTunnelServer noiseWebSocketTunnelServer = new NoiseWebSocketTunnelServer(
|
||||
config.getNoiseWebSocketTunnelConfiguration().port(),
|
||||
config.getNoiseTunnelConfiguration().webSocketPort(),
|
||||
noiseWebSocketTlsCertificateChain,
|
||||
noiseWebSocketTlsPrivateKey,
|
||||
noiseWebSocketEventLoopGroup,
|
||||
noiseTunnelEventLoopGroup,
|
||||
noiseWebSocketDelegatedTaskExecutor,
|
||||
grpcClientConnectionManager,
|
||||
clientPublicKeysManager,
|
||||
config.getNoiseWebSocketTunnelConfiguration().noiseStaticKeyPair(),
|
||||
config.getNoiseTunnelConfiguration().noiseStaticKeyPair(),
|
||||
authenticatedGrpcServerAddress,
|
||||
anonymousGrpcServerAddress,
|
||||
config.getNoiseWebSocketTunnelConfiguration().recognizedProxySecret().value());
|
||||
config.getNoiseTunnelConfiguration().recognizedProxySecret().value());
|
||||
|
||||
final NoiseDirectTunnelServer noiseDirectTunnelServer = new NoiseDirectTunnelServer(
|
||||
config.getNoiseTunnelConfiguration().directPort(),
|
||||
noiseTunnelEventLoopGroup,
|
||||
grpcClientConnectionManager,
|
||||
clientPublicKeysManager,
|
||||
config.getNoiseTunnelConfiguration().noiseStaticKeyPair(),
|
||||
authenticatedGrpcServerAddress,
|
||||
anonymousGrpcServerAddress);
|
||||
|
||||
environment.lifecycle().manage(localEventLoopGroup);
|
||||
environment.lifecycle().manage(dnsResolutionEventLoopGroup);
|
||||
environment.lifecycle().manage(anonymousGrpcServer);
|
||||
environment.lifecycle().manage(authenticatedGrpcServer);
|
||||
environment.lifecycle().manage(noiseWebSocketEventLoopGroup);
|
||||
environment.lifecycle().manage(noiseTunnelEventLoopGroup);
|
||||
environment.lifecycle().manage(noiseWebSocketTunnelServer);
|
||||
environment.lifecycle().manage(noiseDirectTunnelServer);
|
||||
|
||||
final List<Filter> filters = new ArrayList<>();
|
||||
filters.add(remoteDeprecationFilter);
|
||||
|
@ -973,23 +988,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));
|
||||
webSocketEnvironment.jersey()
|
||||
.register(new WebsocketRefreshApplicationEventListener(accountsManager, disconnectionRequestManager));
|
||||
messageDeliveryScheduler, clientReleaseManager, messageDeliveryLoopMonitor, experimentEnrollmentManager));
|
||||
webSocketEnvironment.jersey().register(new RateLimitByIpFilter(rateLimiters));
|
||||
webSocketEnvironment.jersey().register(new RequestStatisticsFilter(TrafficSource.WEBSOCKET));
|
||||
webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);
|
||||
|
@ -1076,15 +1087,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().value(),
|
||||
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),
|
||||
|
@ -1099,8 +1110,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
Clock.systemUTC()),
|
||||
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
|
||||
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager,
|
||||
profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner,
|
||||
config.getCdnConfiguration().bucket(), zkSecretParams, zkProfileOperations, batchIdentityCheckExecutor),
|
||||
profileBadgeConverter, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner,
|
||||
zkSecretParams, zkProfileOperations, batchIdentityCheckExecutor),
|
||||
new ProvisioningController(rateLimiters, provisioningManager),
|
||||
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
|
||||
rateLimiters),
|
||||
|
@ -1133,8 +1144,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));
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -7,10 +7,14 @@ package org.whispersystems.textsecuregcm.auth;
|
|||
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public record AuthenticatedBackupUser(byte[] backupId,
|
||||
public record AuthenticatedBackupUser(
|
||||
byte[] backupId,
|
||||
BackupCredentialType credentialType,
|
||||
BackupLevel backupLevel,
|
||||
String backupDir,
|
||||
String mediaDir) {
|
||||
String mediaDir,
|
||||
@Nullable UserAgent userAgent) {
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -15,6 +15,7 @@ import java.net.Inet6Address;
|
|||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletionException;
|
||||
|
@ -39,16 +40,18 @@ public class CloudflareTurnCredentialsManager {
|
|||
private final List<String> cloudflareTurnUrls;
|
||||
private final List<String> cloudflareTurnUrlsWithIps;
|
||||
private final String cloudflareTurnHostname;
|
||||
private final HttpRequest request;
|
||||
private final HttpRequest getCredentialsRequest;
|
||||
|
||||
private final FaultTolerantHttpClient cloudflareTurnClient;
|
||||
private final DnsNameResolver dnsNameResolver;
|
||||
|
||||
record CredentialRequest(long ttl) {}
|
||||
private final Duration clientCredentialTtl;
|
||||
|
||||
record CloudflareTurnResponse(IceServer iceServers) {
|
||||
private record CredentialRequest(long ttl) {}
|
||||
|
||||
record IceServer(
|
||||
private record CloudflareTurnResponse(IceServer iceServers) {
|
||||
|
||||
private record IceServer(
|
||||
String username,
|
||||
String credential,
|
||||
List<String> urls) {
|
||||
|
@ -56,10 +59,17 @@ public class CloudflareTurnCredentialsManager {
|
|||
}
|
||||
|
||||
public CloudflareTurnCredentialsManager(final String cloudflareTurnApiToken,
|
||||
final String cloudflareTurnEndpoint, final long cloudflareTurnTtl, final List<String> cloudflareTurnUrls,
|
||||
final List<String> cloudflareTurnUrlsWithIps, final String cloudflareTurnHostname,
|
||||
final int cloudflareTurnNumHttpClients, final CircuitBreakerConfiguration circuitBreaker,
|
||||
final ExecutorService executor, final RetryConfiguration retry, final ScheduledExecutorService retryExecutor,
|
||||
final String cloudflareTurnEndpoint,
|
||||
final Duration requestedCredentialTtl,
|
||||
final Duration clientCredentialTtl,
|
||||
final List<String> cloudflareTurnUrls,
|
||||
final List<String> cloudflareTurnUrlsWithIps,
|
||||
final String cloudflareTurnHostname,
|
||||
final int cloudflareTurnNumHttpClients,
|
||||
final CircuitBreakerConfiguration circuitBreaker,
|
||||
final ExecutorService executor,
|
||||
final RetryConfiguration retry,
|
||||
final ScheduledExecutorService retryExecutor,
|
||||
final DnsNameResolver dnsNameResolver) {
|
||||
|
||||
this.cloudflareTurnClient = FaultTolerantHttpClient.newBuilder()
|
||||
|
@ -75,17 +85,24 @@ public class CloudflareTurnCredentialsManager {
|
|||
this.cloudflareTurnHostname = cloudflareTurnHostname;
|
||||
this.dnsNameResolver = dnsNameResolver;
|
||||
|
||||
final String credentialsRequestBody;
|
||||
|
||||
try {
|
||||
final String body = SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(cloudflareTurnTtl));
|
||||
this.request = HttpRequest.newBuilder()
|
||||
credentialsRequestBody =
|
||||
SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(requestedCredentialTtl.toSeconds()));
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
||||
// We repeat the same request to Cloudflare every time, so we can construct it once and re-use it
|
||||
this.getCredentialsRequest = HttpRequest.newBuilder()
|
||||
.uri(URI.create(cloudflareTurnEndpoint))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", String.format("Bearer %s", cloudflareTurnApiToken))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(credentialsRequestBody))
|
||||
.build();
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
|
||||
this.clientCredentialTtl = clientCredentialTtl;
|
||||
}
|
||||
|
||||
public TurnToken retrieveFromCloudflare() throws IOException {
|
||||
|
@ -105,7 +122,7 @@ public class CloudflareTurnCredentialsManager {
|
|||
final Timer.Sample sample = Timer.start();
|
||||
final HttpResponse<String> response;
|
||||
try {
|
||||
response = cloudflareTurnClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
|
||||
response = cloudflareTurnClient.sendAsync(getCredentialsRequest, HttpResponse.BodyHandlers.ofString()).join();
|
||||
sample.stop(Timer.builder(CREDENTIAL_FETCH_TIMER_NAME)
|
||||
.publishPercentileHistogram(true)
|
||||
.tags("outcome", "success")
|
||||
|
@ -130,6 +147,7 @@ public class CloudflareTurnCredentialsManager {
|
|||
return new TurnToken(
|
||||
cloudflareTurnResponse.iceServers().username(),
|
||||
cloudflareTurnResponse.iceServers().credential(),
|
||||
clientCredentialTtl.toSeconds(),
|
||||
cloudflareTurnUrls == null ? Collections.emptyList() : cloudflareTurnUrls,
|
||||
cloudflareTurnComposedUrls,
|
||||
cloudflareTurnHostname
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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 request’s 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -5,13 +5,15 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.List;
|
||||
|
||||
public record TurnToken(
|
||||
String username,
|
||||
String password,
|
||||
@JsonProperty("ttl") long ttlSeconds,
|
||||
@Nonnull List<String> urls,
|
||||
@Nonnull List<String> urlsWithIps,
|
||||
@Nullable String hostname) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -1,34 +1,22 @@
|
|||
package org.whispersystems.textsecuregcm.auth.grpc;
|
||||
|
||||
import io.grpc.Grpc;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import io.netty.channel.local.LocalAddress;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.grpc.ChannelNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
|
||||
abstract class AbstractAuthenticationInterceptor implements ServerInterceptor {
|
||||
|
||||
private final GrpcClientConnectionManager grpcClientConnectionManager;
|
||||
|
||||
private static final Metadata EMPTY_TRAILERS = new Metadata();
|
||||
|
||||
AbstractAuthenticationInterceptor(final GrpcClientConnectionManager grpcClientConnectionManager) {
|
||||
this.grpcClientConnectionManager = grpcClientConnectionManager;
|
||||
}
|
||||
|
||||
protected Optional<AuthenticatedDevice> getAuthenticatedDevice(final ServerCall<?, ?> call) {
|
||||
if (call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR) instanceof LocalAddress localAddress) {
|
||||
return grpcClientConnectionManager.getAuthenticatedDevice(localAddress);
|
||||
} else {
|
||||
throw new AssertionError("Unexpected channel type: " + call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR));
|
||||
}
|
||||
}
|
||||
protected Optional<AuthenticatedDevice> getAuthenticatedDevice(final ServerCall<?, ?> call)
|
||||
throws ChannelNotFoundException {
|
||||
|
||||
protected <ReqT, RespT> ServerCall.Listener<ReqT> closeAsUnauthenticated(final ServerCall<ReqT, RespT> call) {
|
||||
call.close(Status.UNAUTHENTICATED, EMPTY_TRAILERS);
|
||||
return new ServerCall.Listener<>() {};
|
||||
return grpcClientConnectionManager.getAuthenticatedDevice(call);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,17 @@ package org.whispersystems.textsecuregcm.auth.grpc;
|
|||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.Status;
|
||||
import org.whispersystems.textsecuregcm.grpc.ChannelNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
|
||||
/**
|
||||
* A "prohibit authentication" interceptor ensures that requests to endpoints that should be invoked anonymously do not
|
||||
* originate from a channel that is associated with an authenticated device. Calls with an associated authenticated
|
||||
* device are closed with an {@code UNAUTHENTICATED} status.
|
||||
* device are closed with an {@code UNAUTHENTICATED} status. If a call's authentication status cannot be determined
|
||||
* (i.e. because the underlying remote channel closed before the {@code ServerCall} started), the interceptor will
|
||||
* reject the call with a status of {@code UNAVAILABLE}.
|
||||
*/
|
||||
public class ProhibitAuthenticationInterceptor extends AbstractAuthenticationInterceptor {
|
||||
|
||||
|
@ -21,8 +26,15 @@ public class ProhibitAuthenticationInterceptor extends AbstractAuthenticationInt
|
|||
final Metadata headers,
|
||||
final ServerCallHandler<ReqT, RespT> next) {
|
||||
|
||||
try {
|
||||
return getAuthenticatedDevice(call)
|
||||
.map(ignored -> closeAsUnauthenticated(call))
|
||||
// Status.INTERNAL may seem a little surprising here, but if a caller is reaching an authentication-prohibited
|
||||
// service via an authenticated connection, then that's actually a server configuration issue and not a
|
||||
// problem with the client's request.
|
||||
.map(ignored -> ServerInterceptorUtil.closeWithStatus(call, Status.INTERNAL))
|
||||
.orElseGet(() -> next.startCall(call, headers));
|
||||
} catch (final ChannelNotFoundException e) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call, Status.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,12 +5,16 @@ import io.grpc.Contexts;
|
|||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.Status;
|
||||
import org.whispersystems.textsecuregcm.grpc.ChannelNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
|
||||
/**
|
||||
* A "require authentication" interceptor requires that requests be issued from a connection that is associated with an
|
||||
* authenticated device. Calls without an associated authenticated device are closed with an {@code UNAUTHENTICATED}
|
||||
* status.
|
||||
* status. If a call's authentication status cannot be determined (i.e. because the underlying remote channel closed
|
||||
* before the {@code ServerCall} started), the interceptor will reject the call with a status of {@code UNAVAILABLE}.
|
||||
*/
|
||||
public class RequireAuthenticationInterceptor extends AbstractAuthenticationInterceptor {
|
||||
|
||||
|
@ -23,10 +27,17 @@ public class RequireAuthenticationInterceptor extends AbstractAuthenticationInte
|
|||
final Metadata headers,
|
||||
final ServerCallHandler<ReqT, RespT> next) {
|
||||
|
||||
try {
|
||||
return getAuthenticatedDevice(call)
|
||||
.map(authenticatedDevice -> Contexts.interceptCall(Context.current()
|
||||
.withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_DEVICE, authenticatedDevice),
|
||||
call, headers, next))
|
||||
.orElseGet(() -> closeAsUnauthenticated(call));
|
||||
// Status.INTERNAL may seem a little surprising here, but if a caller is reaching an authentication-required
|
||||
// service via an unauthenticated connection, then that's actually a server configuration issue and not a
|
||||
// problem with the client's request.
|
||||
.orElseGet(() -> ServerInterceptorUtil.closeWithStatus(call, Status.INTERNAL));
|
||||
} catch (final ChannelNotFoundException e) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call, Status.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -10,6 +10,8 @@ import io.dropwizard.util.DataSize;
|
|||
import io.grpc.Status;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
|
@ -21,8 +23,8 @@ import java.util.Map;
|
|||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Function;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
|
||||
|
@ -30,14 +32,20 @@ import org.signal.libsignal.zkgroup.VerificationFailedException;
|
|||
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupCredentialType;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;
|
||||
import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.util.AsyncTimerUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
|
@ -57,6 +65,10 @@ public class BackupManager {
|
|||
// How many cdn object copy requests can be outstanding at a time per batch copy-to-backup operation
|
||||
private static final int COPY_CONCURRENCY = 10;
|
||||
|
||||
// How often we should persist the current usage
|
||||
@VisibleForTesting
|
||||
static int USAGE_CHECKPOINT_COUNT = 10;
|
||||
|
||||
|
||||
private static final String ZK_AUTHN_COUNTER_NAME = MetricsUtil.name(BackupManager.class, "authentication");
|
||||
private static final String ZK_AUTHZ_FAILURE_COUNTER_NAME = MetricsUtil.name(BackupManager.class,
|
||||
|
@ -71,6 +83,8 @@ public class BackupManager {
|
|||
private static final String SUCCESS_TAG_NAME = "success";
|
||||
private static final String FAILURE_REASON_TAG_NAME = "reason";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BackupManager.class);
|
||||
|
||||
private final BackupsDb backupsDb;
|
||||
private final GenericServerSecretParams serverSecretParams;
|
||||
private final RateLimiters rateLimiters;
|
||||
|
@ -214,29 +228,39 @@ public class BackupManager {
|
|||
checkBackupLevel(backupUser, BackupLevel.PAID);
|
||||
checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);
|
||||
|
||||
return Mono
|
||||
// Figure out how many objects we're allowed to copy, updating the quota usage for the amount we are allowed
|
||||
.fromFuture(enforceQuota(backupUser, toCopy))
|
||||
|
||||
// Copy the ones we have enough quota to hold
|
||||
return Mono.fromFuture(() -> allowedCopies(backupUser, toCopy))
|
||||
.flatMapMany(quotaResult -> Flux.concat(
|
||||
|
||||
// These fit in our remaining quota, so perform the copy. If the copy fails, our estimated quota usage may not
|
||||
// be exact since we already updated our usage. We make a best-effort attempt to undo the usage update if we
|
||||
// know that the copied failed for sure though.
|
||||
Flux.fromIterable(quotaResult.requestsToCopy()).flatMapSequential(
|
||||
copyParams -> copyToBackup(backupUser, copyParams)
|
||||
// Perform copies for requests that fit in our quota, first updating the usage. If the copy fails, our
|
||||
// estimated quota usage may not be exact since we update usage first. We make a best-effort attempt
|
||||
// to undo the usage update if we know that the copied failed for sure.
|
||||
Flux.fromIterable(quotaResult.requestsToCopy())
|
||||
|
||||
// Update the usage in reasonable chunk sizes to bound how out of sync our claimed and actual usage gets
|
||||
.buffer(USAGE_CHECKPOINT_COUNT)
|
||||
.concatMap(copyParameters -> {
|
||||
final long quotaToConsume = copyParameters.stream()
|
||||
.mapToLong(CopyParameters::destinationObjectSize)
|
||||
.sum();
|
||||
return Mono
|
||||
.fromFuture(backupsDb.trackMedia(backupUser, copyParameters.size(), quotaToConsume))
|
||||
.thenMany(Flux.fromIterable(copyParameters));
|
||||
})
|
||||
|
||||
// Actually perform the copies now that we've updated the quota
|
||||
.flatMapSequential(copyParams -> copyToBackup(backupUser, copyParams)
|
||||
.flatMap(copyResult -> switch (copyResult.outcome()) {
|
||||
case SUCCESS -> Mono.just(copyResult);
|
||||
case SOURCE_WRONG_LENGTH, SOURCE_NOT_FOUND, OUT_OF_QUOTA -> Mono
|
||||
.fromFuture(this.backupsDb.trackMedia(backupUser, -1, -copyParams.destinationObjectSize()))
|
||||
.thenReturn(copyResult);
|
||||
}),
|
||||
COPY_CONCURRENCY),
|
||||
COPY_CONCURRENCY, 1),
|
||||
|
||||
// There wasn't enough quota remaining to perform these copies
|
||||
Flux.fromIterable(quotaResult.requestsToReject())
|
||||
.map(arg -> new CopyResult(CopyResult.Outcome.OUT_OF_QUOTA, arg.destinationMediaId(), null))));
|
||||
.map(arg -> new CopyResult(CopyResult.Outcome.OUT_OF_QUOTA, arg.destinationMediaId(), null))
|
||||
));
|
||||
}
|
||||
|
||||
private Mono<CopyResult> copyToBackup(final AuthenticatedBackupUser backupUser, final CopyParameters copyParameters) {
|
||||
|
@ -262,15 +286,14 @@ public class BackupManager {
|
|||
private record QuotaResult(List<CopyParameters> requestsToCopy, List<CopyParameters> requestsToReject) {}
|
||||
|
||||
/**
|
||||
* Determine which copy requests can be performed with the user's remaining quota and update the used quota. If a copy
|
||||
* request subsequently fails, the caller should attempt to restore the quota for the failed copy.
|
||||
* Determine which copy requests can be performed with the user's remaining quota. This does not update the quota.
|
||||
*
|
||||
* @param backupUser The user quota to update
|
||||
* @param backupUser The user quota to check against
|
||||
* @param toCopy The proposed copy requests
|
||||
* @return QuotaResult indicating which requests fit into the remaining quota and which requests should be rejected
|
||||
* with {@link CopyResult.Outcome#OUT_OF_QUOTA}
|
||||
* @return list of QuotaResult indicating which requests fit into the remaining quota and which requests should be
|
||||
* rejected with {@link CopyResult.Outcome#OUT_OF_QUOTA}
|
||||
*/
|
||||
private CompletableFuture<QuotaResult> enforceQuota(
|
||||
private CompletableFuture<QuotaResult> allowedCopies(
|
||||
final AuthenticatedBackupUser backupUser,
|
||||
final List<CopyParameters> toCopy) {
|
||||
final long totalBytesAdded = toCopy.stream()
|
||||
|
@ -300,28 +323,32 @@ public class BackupManager {
|
|||
.thenApply(ignored -> usage))
|
||||
.whenComplete((newUsage, throwable) -> {
|
||||
boolean usageChanged = throwable == null && !newUsage.equals(info.usageInfo());
|
||||
Metrics.counter(USAGE_RECALCULATION_COUNTER_NAME, "usageChanged", String.valueOf(usageChanged))
|
||||
Metrics.counter(USAGE_RECALCULATION_COUNTER_NAME, Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
|
||||
Tag.of("usageChanged", String.valueOf(usageChanged))))
|
||||
.increment();
|
||||
})
|
||||
.thenApply(newUsage -> MAX_TOTAL_BACKUP_MEDIA_BYTES - newUsage.bytesUsed());
|
||||
})
|
||||
.thenCompose(remainingQuota -> {
|
||||
.thenApply(remainingQuota -> {
|
||||
// Figure out how many of the requested objects fit in the remaining quota
|
||||
final int index = indexWhereTotalExceeds(toCopy, CopyParameters::destinationObjectSize,
|
||||
remainingQuota);
|
||||
final QuotaResult result = new QuotaResult(toCopy.subList(0, index),
|
||||
toCopy.subList(index, toCopy.size()));
|
||||
if (index == 0) {
|
||||
// Skip the usage update if we're not able to write anything
|
||||
return CompletableFuture.completedFuture(result);
|
||||
return new QuotaResult(toCopy.subList(0, index), toCopy.subList(index, toCopy.size()));
|
||||
});
|
||||
}
|
||||
|
||||
// Update the usage
|
||||
final long quotaToConsume = result.requestsToCopy.stream()
|
||||
.mapToLong(CopyParameters::destinationObjectSize)
|
||||
.sum();
|
||||
return backupsDb.trackMedia(backupUser, index, quotaToConsume).thenApply(ignored -> result);
|
||||
});
|
||||
public record RecalculationResult(UsageInfo oldUsage, UsageInfo newUsage) {}
|
||||
public CompletionStage<Optional<RecalculationResult>> recalculateQuota(final StoredBackupAttributes storedBackupAttributes) {
|
||||
if (StringUtils.isBlank(storedBackupAttributes.backupDir()) || StringUtils.isBlank(storedBackupAttributes.mediaDir())) {
|
||||
return CompletableFuture.completedFuture(Optional.empty());
|
||||
}
|
||||
final String cdnPath = cdnMediaDirectory(storedBackupAttributes.backupDir(), storedBackupAttributes.mediaDir());
|
||||
return this.remoteStorageManager.calculateBytesUsed(cdnPath).thenCompose(usage ->
|
||||
backupsDb.setMediaUsage(storedBackupAttributes, usage).thenApply(ignored ->
|
||||
Optional.of(new RecalculationResult(
|
||||
new UsageInfo(storedBackupAttributes.bytesUsed(), storedBackupAttributes.numObjects()),
|
||||
usage))));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -422,45 +449,79 @@ public class BackupManager {
|
|||
|
||||
return Flux.usingWhen(
|
||||
|
||||
// Gather usage updates into the UsageBatcher to apply during the cleanup operation
|
||||
// Gather usage updates into the UsageBatcher so we don't have to update our backup record on every delete
|
||||
Mono.just(new UsageBatcher()),
|
||||
|
||||
// Deletes the objects, returning their former location. Tracks bytes removed so the quota can be updated on
|
||||
// completion
|
||||
batcher -> Flux.fromIterable(storageDescriptors)
|
||||
.flatMapSequential(sd -> Mono
|
||||
// Delete the object
|
||||
.fromCompletionStage(remoteStorageManager.delete(cdnMediaPath(backupUser, sd.key())))
|
||||
// Track how much the remote storage manager indicated was deleted as part of the operation
|
||||
.doOnNext(deletedBytes -> batcher.update(-deletedBytes))
|
||||
.thenReturn(sd), DELETION_CONCURRENCY),
|
||||
|
||||
// On cleanup, update the quota using whatever updates were accumulated in the batcher
|
||||
batcher ->
|
||||
Mono.fromFuture(backupsDb.trackMedia(backupUser, batcher.countDelta.get(), batcher.usageDelta.get())));
|
||||
// Delete the objects, allowing DELETION_CONCURRENCY operations out at a time
|
||||
.flatMapSequential(
|
||||
sd -> Mono.fromCompletionStage(remoteStorageManager.delete(cdnMediaPath(backupUser, sd.key()))),
|
||||
DELETION_CONCURRENCY)
|
||||
.zipWithIterable(storageDescriptors)
|
||||
|
||||
// Track how much the remote storage manager indicated was deleted as part of the operation
|
||||
.concatMap(deletedBytesAndStorageDescriptor -> {
|
||||
final long deletedBytes = deletedBytesAndStorageDescriptor.getT1();
|
||||
final StorageDescriptor sd = deletedBytesAndStorageDescriptor.getT2();
|
||||
|
||||
// If it has been a while, perform a checkpoint to make sure our usage doesn't drift too much
|
||||
if (batcher.update(-deletedBytes)) {
|
||||
final UsageBatcher.UsageUpdate usageUpdate = batcher.getAndReset();
|
||||
return Mono
|
||||
.fromFuture(backupsDb.trackMedia(backupUser, usageUpdate.countDelta, usageUpdate.bytesDelta))
|
||||
.doOnError(throwable ->
|
||||
log.warn("Failed to update delta {} after successful delete operation", usageUpdate, throwable))
|
||||
.thenReturn(sd);
|
||||
} else {
|
||||
return Mono.just(sd);
|
||||
}
|
||||
}),
|
||||
|
||||
// On cleanup, update the quota using whatever remaining updates were accumulated in the batcher
|
||||
batcher -> {
|
||||
final UsageBatcher.UsageUpdate update = batcher.getAndReset();
|
||||
return Mono
|
||||
.fromFuture(backupsDb.trackMedia(backupUser, update.countDelta, update.bytesDelta))
|
||||
.doOnError(throwable ->
|
||||
log.warn("Failed to update delta {} after successful delete operation", update, throwable));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track pending media usage updates
|
||||
* Track pending media usage updates. Not thread safe!
|
||||
*/
|
||||
private static class UsageBatcher {
|
||||
|
||||
AtomicLong countDelta = new AtomicLong();
|
||||
AtomicLong usageDelta = new AtomicLong();
|
||||
private long runningCountDelta = 0;
|
||||
private long runningBytesDelta = 0;
|
||||
|
||||
record UsageUpdate(long countDelta, long bytesDelta) {}
|
||||
|
||||
/**
|
||||
* Stage a usage update that will be applied later
|
||||
* Stage a usage update. Returns true when it is time to make a checkpoint
|
||||
*
|
||||
* @param bytesDelta The amount of bytes that should be tracked as used (or if negative, freed). If the delta is
|
||||
* non-zero, the count will also be updated.
|
||||
* @return true if we should persist the usage
|
||||
*/
|
||||
void update(long bytesDelta) {
|
||||
if (bytesDelta < 0) {
|
||||
countDelta.decrementAndGet();
|
||||
} else if (bytesDelta > 0) {
|
||||
countDelta.incrementAndGet();
|
||||
boolean update(long bytesDelta) {
|
||||
this.runningCountDelta += Long.signum(bytesDelta);
|
||||
this.runningBytesDelta += bytesDelta;
|
||||
return Math.abs(runningCountDelta) >= USAGE_CHECKPOINT_COUNT;
|
||||
}
|
||||
usageDelta.addAndGet(bytesDelta);
|
||||
|
||||
/**
|
||||
* Get the current usage delta, and set the delta to 0
|
||||
* @return A {@link UsageUpdate} to apply
|
||||
*/
|
||||
UsageUpdate getAndReset() {
|
||||
final UsageUpdate update = new UsageUpdate(runningCountDelta, runningBytesDelta);
|
||||
runningCountDelta = 0;
|
||||
runningBytesDelta = 0;
|
||||
return update;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -481,7 +542,8 @@ public class BackupManager {
|
|||
*/
|
||||
public CompletableFuture<AuthenticatedBackupUser> authenticateBackupUser(
|
||||
final BackupAuthCredentialPresentation presentation,
|
||||
final byte[] signature) {
|
||||
final byte[] signature,
|
||||
final String userAgentString) {
|
||||
final PresentationSignatureVerifier signatureVerifier = verifyPresentation(presentation);
|
||||
return backupsDb
|
||||
.retrieveAuthenticationData(presentation.getBackupId())
|
||||
|
@ -499,12 +561,20 @@ public class BackupManager {
|
|||
final Pair<BackupCredentialType, BackupLevel> credentialTypeAndBackupLevel =
|
||||
signatureVerifier.verifySignature(signature, authenticationData.publicKey());
|
||||
|
||||
UserAgent userAgent;
|
||||
try {
|
||||
userAgent = UserAgentUtil.parseUserAgentString(userAgentString);
|
||||
} catch (UnrecognizedUserAgentException e) {
|
||||
userAgent = null;
|
||||
}
|
||||
|
||||
return new AuthenticatedBackupUser(
|
||||
presentation.getBackupId(),
|
||||
credentialTypeAndBackupLevel.first(),
|
||||
credentialTypeAndBackupLevel.second(),
|
||||
authenticationData.backupDir(),
|
||||
authenticationData.mediaDir());
|
||||
authenticationData.mediaDir(),
|
||||
userAgent);
|
||||
})
|
||||
.thenApply(result -> {
|
||||
Metrics.counter(ZK_AUTHN_COUNTER_NAME, SUCCESS_TAG_NAME, String.valueOf(true)).increment();
|
||||
|
@ -634,8 +704,9 @@ public class BackupManager {
|
|||
@VisibleForTesting
|
||||
static void checkBackupLevel(final AuthenticatedBackupUser backupUser, final BackupLevel backupLevel) {
|
||||
if (backupUser.backupLevel().compareTo(backupLevel) < 0) {
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME,
|
||||
FAILURE_REASON_TAG_NAME, "level")
|
||||
Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME, Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
|
||||
Tag.of(FAILURE_REASON_TAG_NAME, "level")))
|
||||
.increment();
|
||||
|
||||
throw Status.PERMISSION_DENIED
|
||||
|
@ -678,8 +749,12 @@ public class BackupManager {
|
|||
return "%s/%s".formatted(backupUser.backupDir(), MESSAGE_BACKUP_NAME);
|
||||
}
|
||||
|
||||
private static String cdnMediaDirectory(final String backupDir, final String mediaDir) {
|
||||
return "%s/%s/".formatted(backupDir, mediaDir);
|
||||
}
|
||||
|
||||
private static String cdnMediaDirectory(final AuthenticatedBackupUser backupUser) {
|
||||
return "%s/%s/".formatted(backupUser.backupDir(), backupUser.mediaDir());
|
||||
return cdnMediaDirectory(backupUser.backupDir(), backupUser.mediaDir());
|
||||
}
|
||||
|
||||
private static String cdnMediaPath(final AuthenticatedBackupUser backupUser, final byte[] mediaId) {
|
||||
|
|
|
@ -11,6 +11,7 @@ import java.security.NoSuchAlgorithmException;
|
|||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
@ -21,12 +22,18 @@ import java.util.Map;
|
|||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Predicate;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
@ -38,6 +45,7 @@ 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.ReturnValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.Update;
|
||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
||||
|
@ -79,6 +87,10 @@ public class BackupsDb {
|
|||
|
||||
private final SecureRandom secureRandom;
|
||||
|
||||
private static final String NUM_OBJECTS_SUMMARY_NAME = MetricsUtil.name(BackupsDb.class, "numObjects");
|
||||
private static final String BYTES_USED_SUMMARY_NAME = MetricsUtil.name(BackupsDb.class, "bytesUsed");
|
||||
private static final String BACKUPS_COUNTER_NAME = MetricsUtil.name(BackupsDb.class, "backups");
|
||||
|
||||
// The backups table
|
||||
|
||||
// B: 16 bytes that identifies the backup
|
||||
|
@ -217,12 +229,10 @@ public class BackupsDb {
|
|||
*/
|
||||
CompletableFuture<Void> trackMedia(final AuthenticatedBackupUser backupUser, final long mediaCountDelta,
|
||||
final long mediaBytesDelta) {
|
||||
final Instant now = clock.instant();
|
||||
return dynamoClient
|
||||
.updateItem(
|
||||
// Update the media quota and TTL
|
||||
UpdateBuilder.forUser(backupTableName, backupUser)
|
||||
.setRefreshTimes(now)
|
||||
.incrementMediaBytes(mediaBytesDelta)
|
||||
.incrementMediaCount(mediaCountDelta)
|
||||
.updateItemBuilder()
|
||||
|
@ -237,12 +247,15 @@ public class BackupsDb {
|
|||
* @param backupUser an already authorized backup user
|
||||
*/
|
||||
CompletableFuture<Void> ttlRefresh(final AuthenticatedBackupUser backupUser) {
|
||||
final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
// update message backup TTL
|
||||
return dynamoClient.updateItem(UpdateBuilder.forUser(backupTableName, backupUser)
|
||||
.setRefreshTimes(clock)
|
||||
.setRefreshTimes(today)
|
||||
.updateItemBuilder()
|
||||
.returnValues(ReturnValue.ALL_OLD)
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
.thenAccept(updateItemResponse ->
|
||||
updateMetricsAfterRefresh(backupUser, today, updateItemResponse.attributes()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -251,14 +264,49 @@ public class BackupsDb {
|
|||
* @param backupUser an already authorized backup user
|
||||
*/
|
||||
CompletableFuture<Void> addMessageBackup(final AuthenticatedBackupUser backupUser) {
|
||||
final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);
|
||||
// this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp
|
||||
return dynamoClient.updateItem(
|
||||
UpdateBuilder.forUser(backupTableName, backupUser)
|
||||
.setRefreshTimes(clock)
|
||||
.setRefreshTimes(today)
|
||||
.setCdn(BACKUP_CDN)
|
||||
.updateItemBuilder()
|
||||
.returnValues(ReturnValue.ALL_OLD)
|
||||
.build())
|
||||
.thenRun(Util.NOOP);
|
||||
.thenAccept(updateItemResponse ->
|
||||
updateMetricsAfterRefresh(backupUser, today, updateItemResponse.attributes()));
|
||||
}
|
||||
|
||||
private void updateMetricsAfterRefresh(final AuthenticatedBackupUser backupUser, final Instant today, final Map<String, AttributeValue> item) {
|
||||
final Instant previousRefreshTime = Instant.ofEpochSecond(
|
||||
AttributeValues.getLong(item, ATTR_LAST_REFRESH, 0L));
|
||||
// Only publish a metric update once per day
|
||||
if (previousRefreshTime.isBefore(today)) {
|
||||
final long mediaCount = AttributeValues.getLong(item, ATTR_MEDIA_COUNT, 0L);
|
||||
final long bytesUsed = AttributeValues.getLong(item, ATTR_MEDIA_BYTES_USED, 0L);
|
||||
final Tags tags = Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),
|
||||
Tag.of("tier", backupUser.backupLevel().name()));
|
||||
|
||||
DistributionSummary.builder(NUM_OBJECTS_SUMMARY_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(mediaCount);
|
||||
DistributionSummary.builder(BYTES_USED_SUMMARY_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(mediaCount);
|
||||
|
||||
// Report that the backup is out of quota if it cannot store a max size media object
|
||||
final boolean quotaExhausted = bytesUsed >=
|
||||
(BackupManager.MAX_TOTAL_BACKUP_MEDIA_BYTES - BackupManager.MAX_MEDIA_OBJECT_SIZE);
|
||||
|
||||
Metrics.counter(BACKUPS_COUNTER_NAME,
|
||||
tags.and("quotaExhausted", String.valueOf(quotaExhausted)))
|
||||
.increment();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -365,8 +413,16 @@ public class BackupsDb {
|
|||
}
|
||||
|
||||
CompletableFuture<Void> setMediaUsage(final AuthenticatedBackupUser backupUser, UsageInfo usageInfo) {
|
||||
return setMediaUsage(UpdateBuilder.forUser(backupTableName, backupUser), usageInfo);
|
||||
}
|
||||
|
||||
CompletableFuture<Void> setMediaUsage(final StoredBackupAttributes backupAttributes, UsageInfo usageInfo) {
|
||||
return setMediaUsage(new UpdateBuilder(backupTableName, BackupLevel.PAID, backupAttributes.hashedBackupId()), usageInfo);
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> setMediaUsage(final UpdateBuilder updateBuilder, UsageInfo usageInfo) {
|
||||
return dynamoClient.updateItem(
|
||||
UpdateBuilder.forUser(backupTableName, backupUser)
|
||||
updateBuilder
|
||||
.addSetExpression("#mediaBytesUsed = :mediaBytesUsed",
|
||||
Map.entry("#mediaBytesUsed", ATTR_MEDIA_BYTES_USED),
|
||||
Map.entry(":mediaBytesUsed", AttributeValues.n(usageInfo.bytesUsed())))
|
||||
|
@ -459,13 +515,18 @@ public class BackupsDb {
|
|||
"#refresh", ATTR_LAST_REFRESH,
|
||||
"#mediaRefresh", ATTR_LAST_MEDIA_REFRESH,
|
||||
"#bytesUsed", ATTR_MEDIA_BYTES_USED,
|
||||
"#numObjects", ATTR_MEDIA_COUNT))
|
||||
.projectionExpression("#backupIdHash, #refresh, #mediaRefresh, #bytesUsed, #numObjects")
|
||||
"#numObjects", ATTR_MEDIA_COUNT,
|
||||
"#backupDir", ATTR_BACKUP_DIR,
|
||||
"#mediaDir", ATTR_MEDIA_DIR))
|
||||
.projectionExpression("#backupIdHash, #refresh, #mediaRefresh, #bytesUsed, #numObjects, #backupDir, #mediaDir")
|
||||
.build())
|
||||
.items())
|
||||
.sequential()
|
||||
.filter(item -> item.containsKey(KEY_BACKUP_ID_HASH))
|
||||
.map(item -> new StoredBackupAttributes(
|
||||
AttributeValues.getByteArray(item, KEY_BACKUP_ID_HASH, null),
|
||||
AttributeValues.getString(item, ATTR_BACKUP_DIR, null),
|
||||
AttributeValues.getString(item, ATTR_MEDIA_DIR, null),
|
||||
Instant.ofEpochSecond(AttributeValues.getLong(item, ATTR_LAST_REFRESH, 0L)),
|
||||
Instant.ofEpochSecond(AttributeValues.getLong(item, ATTR_LAST_MEDIA_REFRESH, 0L)),
|
||||
AttributeValues.getLong(item, ATTR_MEDIA_BYTES_USED, 0L),
|
||||
|
@ -707,17 +768,20 @@ public class BackupsDb {
|
|||
};
|
||||
}
|
||||
|
||||
UpdateBuilder setRefreshTimes(final Clock clock) {
|
||||
return setRefreshTimes(clock.instant().truncatedTo(ChronoUnit.DAYS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the lastRefresh time as part of the update
|
||||
* <p>
|
||||
* This always updates lastRefreshTime, and updates lastMediaRefreshTime if the backup user has the appropriate
|
||||
* level.
|
||||
*/
|
||||
UpdateBuilder setRefreshTimes(final Clock clock) {
|
||||
return this.setRefreshTimes(clock.instant());
|
||||
}
|
||||
|
||||
UpdateBuilder setRefreshTimes(final Instant refreshTime) {
|
||||
if (!refreshTime.truncatedTo(ChronoUnit.DAYS).equals(refreshTime)) {
|
||||
throw new IllegalArgumentException("Refresh time must be day aligned");
|
||||
}
|
||||
addSetExpression("#lastRefreshTime = :lastRefreshTime",
|
||||
Map.entry("#lastRefreshTime", ATTR_LAST_REFRESH),
|
||||
Map.entry(":lastRefreshTime", AttributeValues.n(refreshTime.getEpochSecond())));
|
||||
|
|
|
@ -54,6 +54,8 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
|||
private static final String OPERATION_TAG_NAME = "op";
|
||||
private static final String STATUS_TAG_NAME = "status";
|
||||
|
||||
private static final String OBJECT_REMOVED_ON_DELETE_COUNTER_NAME = MetricsUtil.name(Cdn3RemoteStorageManager.class, "objectRemovedOnDelete");
|
||||
|
||||
public Cdn3RemoteStorageManager(
|
||||
final ExecutorService httpExecutor,
|
||||
final ScheduledExecutorService retryExecutor,
|
||||
|
@ -111,6 +113,10 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
|||
.build();
|
||||
return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenAccept(response -> {
|
||||
Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,
|
||||
OPERATION_TAG_NAME, "copy",
|
||||
STATUS_TAG_NAME, Integer.toString(response.statusCode()))
|
||||
.increment();
|
||||
if (response.statusCode() == Response.Status.NOT_FOUND.getStatusCode()) {
|
||||
throw ExceptionUtils.wrap(new SourceObjectNotFoundException());
|
||||
} else if (response.statusCode() == Response.Status.CONFLICT.getStatusCode()) {
|
||||
|
@ -259,6 +265,7 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
|||
record DeleteResponse(@NotNull long bytesDeleted) {}
|
||||
|
||||
public CompletionStage<Long> delete(final String key) {
|
||||
final Timer.Sample sample = Timer.start();
|
||||
final HttpRequest request = HttpRequest.newBuilder().DELETE()
|
||||
.uri(URI.create(deleteUrl(key)))
|
||||
.header(CLIENT_ID_HEADER, clientId)
|
||||
|
@ -271,11 +278,17 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
|
|||
STATUS_TAG_NAME, Integer.toString(response.statusCode()))
|
||||
.increment();
|
||||
try {
|
||||
return parseDeleteResponse(response);
|
||||
long bytesDeleted = parseDeleteResponse(response);
|
||||
Metrics.counter(OBJECT_REMOVED_ON_DELETE_COUNTER_NAME,
|
||||
"removed", Boolean.toString(bytesDeleted > 0))
|
||||
.increment();
|
||||
return bytesDeleted;
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.wrap(e);
|
||||
}
|
||||
});
|
||||
})
|
||||
.whenComplete((ignored, ignoredException) ->
|
||||
sample.stop(Metrics.timer(STORAGE_MANAGER_TIMER_NAME, OPERATION_TAG_NAME, "delete")));
|
||||
}
|
||||
|
||||
private long parseDeleteResponse(final HttpResponse<InputStream> httpDeleteResponse) throws IOException {
|
||||
|
|
|
@ -9,11 +9,19 @@ import java.time.Instant;
|
|||
/**
|
||||
* Attributes stored in the backups table for a single backup id
|
||||
*
|
||||
* @param hashedBackupId The hashed backup-id of this entry
|
||||
* @param backupDir The cdn backupDir of this entry
|
||||
* @param mediaDir The cdn mediaDir (within the backupDir) of this entry
|
||||
* @param lastRefresh The last time the record was updated with a messages or media tier credential
|
||||
* @param lastMediaRefresh The last time the record was updated with a media tier credential
|
||||
* @param bytesUsed The number of media bytes used by the backup
|
||||
* @param numObjects The number of media objects used byt the backup
|
||||
*/
|
||||
public record StoredBackupAttributes(
|
||||
Instant lastRefresh, Instant lastMediaRefresh,
|
||||
long bytesUsed, long numObjects) {}
|
||||
byte[] hashedBackupId,
|
||||
String backupDir,
|
||||
String mediaDir,
|
||||
Instant lastRefresh,
|
||||
Instant lastMediaRefresh,
|
||||
long bytesUsed,
|
||||
long numObjects) {}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
) {
|
||||
}
|
|
@ -5,17 +5,38 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
|
||||
/**
|
||||
* Configuration properties for Cloudflare TURN integration.
|
||||
*
|
||||
* @param apiToken the API token to use when requesting TURN tokens from Cloudflare
|
||||
* @param endpoint the URI of the Cloudflare API endpoint that vends TURN tokens
|
||||
* @param requestedCredentialTtl the lifetime of TURN tokens to request from Cloudflare
|
||||
* @param clientCredentialTtl the time clients may cache a TURN token; must be less than or equal to {@link #requestedCredentialTtl}
|
||||
* @param urls a collection of TURN URLs to include verbatim in responses to clients
|
||||
* @param urlsWithIps a collection of {@link String#format(String, Object...)} patterns to be populated with resolved IP
|
||||
* addresses for {@link #hostname} in responses to clients; each pattern must include a single
|
||||
* {@code %s} placeholder for the IP address
|
||||
* @param circuitBreaker a circuit breaker for requests to Cloudflare
|
||||
* @param retry a retry policy for requests to Cloudflare
|
||||
* @param hostname the hostname to resolve to IP addresses for use with {@link #urlsWithIps}; also transmitted to
|
||||
* clients for use as an SNI when connecting to pre-resolved hosts
|
||||
* @param numHttpClients the number of parallel HTTP clients to use to communicate with Cloudflare
|
||||
*/
|
||||
public record CloudflareTurnConfiguration(@NotNull SecretString apiToken,
|
||||
@NotBlank String endpoint,
|
||||
@NotBlank long ttl,
|
||||
@NotNull Duration requestedCredentialTtl,
|
||||
@NotNull Duration clientCredentialTtl,
|
||||
@NotNull @NotEmpty @Valid List<@NotBlank String> urls,
|
||||
@NotNull @NotEmpty @Valid List<@NotBlank String> urlsWithIps,
|
||||
@NotNull @Valid CircuitBreakerConfiguration circuitBreaker,
|
||||
|
@ -35,4 +56,10 @@ public record CloudflareTurnConfiguration(@NotNull SecretString apiToken,
|
|||
retry = new RetryConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
public boolean isClientTtlShorterThanRequestedTtl() {
|
||||
return clientCredentialTtl.compareTo(requestedCredentialTtl) <= 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -10,7 +10,8 @@ import org.signal.libsignal.protocol.ecc.ECPrivateKey;
|
|||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
|
||||
public record NoiseWebSocketTunnelConfiguration(@Positive int port,
|
||||
public record NoiseTunnelConfiguration(@Positive int webSocketPort,
|
||||
@Positive int directPort,
|
||||
@Nullable String tlsKeyStoreFile,
|
||||
@Nullable String tlsKeyStoreEntryAlias,
|
||||
@Nullable SecretString tlsKeyStorePassword,
|
|
@ -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) {
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
|
@ -12,7 +13,7 @@ import org.signal.libsignal.protocol.ecc.ECPrivateKey;
|
|||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
public record UnidentifiedDeliveryConfiguration(@NotNull SecretBytes certificate,
|
||||
public record UnidentifiedDeliveryConfiguration(@NotNull @NotEmpty byte[] certificate,
|
||||
@ExactlySize(32) SecretBytes privateKey,
|
||||
int expiresDays) {
|
||||
public ECPrivateKey ecPrivateKey() throws InvalidKeyException {
|
||||
|
|
|
@ -46,10 +46,6 @@ public class DynamicConfiguration {
|
|||
@Valid
|
||||
DynamicMessagePersisterConfiguration messagePersister = new DynamicMessagePersisterConfiguration();
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
DynamicRateLimitPolicy rateLimitPolicy = new DynamicRateLimitPolicy(false);
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
DynamicRegistrationConfiguration registrationConfiguration = new DynamicRegistrationConfiguration(false);
|
||||
|
@ -68,7 +64,7 @@ public class DynamicConfiguration {
|
|||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
Map<ClientPlatform, Semver> minimumRestFreeVersion = Map.of();
|
||||
DynamicRestDeprecationConfiguration restDeprecation = new DynamicRestDeprecationConfiguration(Map.of());
|
||||
|
||||
public Optional<DynamicExperimentEnrollmentConfiguration> getExperimentEnrollmentConfiguration(
|
||||
final String experimentName) {
|
||||
|
@ -100,10 +96,6 @@ public class DynamicConfiguration {
|
|||
return messagePersister;
|
||||
}
|
||||
|
||||
public DynamicRateLimitPolicy getRateLimitPolicy() {
|
||||
return rateLimitPolicy;
|
||||
}
|
||||
|
||||
public DynamicRegistrationConfiguration getRegistrationConfiguration() {
|
||||
return registrationConfiguration;
|
||||
}
|
||||
|
@ -120,8 +112,8 @@ public class DynamicConfiguration {
|
|||
return svrStatusCodesToIgnoreForAccountDeletion;
|
||||
}
|
||||
|
||||
public Map<ClientPlatform, Semver> minimumRestFreeVersion() {
|
||||
return minimumRestFreeVersion;
|
||||
public DynamicRestDeprecationConfiguration restDeprecation() {
|
||||
return restDeprecation;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
public record DynamicRateLimitPolicy(boolean failOpen) {}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
import com.vdurmont.semver4j.Semver;
|
||||
import java.util.Map;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
||||
public record DynamicRestDeprecationConfiguration(Map<ClientPlatform, PlatformConfiguration> platforms) {
|
||||
public record PlatformConfiguration(Semver minimumRestFreeVersion, int universalRolloutPercent) {}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
@NotNull @Valid final ChangeNumberRequest request, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString,
|
||||
@Context final ContainerRequestContext requestContext)
|
||||
throws RateLimitExceededException, InterruptedException {
|
||||
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(
|
||||
|
|
|
@ -5,8 +5,6 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
|
@ -17,9 +15,6 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|||
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.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
|
@ -42,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;
|
||||
|
@ -76,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")
|
||||
|
@ -93,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;
|
||||
|
@ -140,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,
|
||||
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(
|
||||
|
@ -192,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())
|
||||
|
||||
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(
|
||||
|
@ -256,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) {
|
||||
|
@ -264,9 +280,14 @@ public class ArchiveController {
|
|||
final Map<BackupCredentialType, List<BackupAuthCredentialsResponse.BackupAuthCredential>> credentialsByType =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
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(
|
||||
auth.getAccount(),
|
||||
account,
|
||||
credentialType,
|
||||
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
|
||||
.thenAccept(credentials -> {
|
||||
|
@ -285,6 +306,7 @@ public class ArchiveController {
|
|||
.collect(Collectors.toMap(
|
||||
e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),
|
||||
Map.Entry::getValue))));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
@ -347,7 +369,8 @@ 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))
|
||||
@NotNull
|
||||
|
@ -361,7 +384,7 @@ public class ArchiveController {
|
|||
if (account.isPresent()) {
|
||||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature)
|
||||
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
|
||||
.thenApply(user -> backupManager.generateReadAuth(user, cdn))
|
||||
.thenApply(ReadAuthResponse::new);
|
||||
}
|
||||
|
@ -398,7 +421,8 @@ 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))
|
||||
@NotNull
|
||||
|
@ -411,7 +435,7 @@ public class ArchiveController {
|
|||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
|
||||
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature)
|
||||
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
|
||||
.thenCompose(backupManager::backupInfo)
|
||||
.thenApply(backupInfo -> new BackupInfoResponse(
|
||||
backupInfo.cdn(),
|
||||
|
@ -443,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
|
||||
|
@ -454,6 +478,9 @@ public class ArchiveController {
|
|||
@HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,
|
||||
|
||||
@Valid @NotNull SetPublicKeyRequest setPublicKeyRequest) {
|
||||
if (account.isPresent()) {
|
||||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
return backupManager
|
||||
.setPublicKey(presentation.presentation, signature.signature, setPublicKeyRequest.backupIdPublicKey)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
|
@ -480,7 +507,8 @@ 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))
|
||||
@NotNull
|
||||
|
@ -492,7 +520,7 @@ public class ArchiveController {
|
|||
if (account.isPresent()) {
|
||||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature)
|
||||
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
|
||||
.thenCompose(backupManager::createMessageBackupUploadDescriptor)
|
||||
.thenApply(result -> new UploadDescriptorResponse(
|
||||
result.cdn(),
|
||||
|
@ -516,7 +544,9 @@ 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,
|
||||
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@NotNull
|
||||
|
@ -528,7 +558,7 @@ public class ArchiveController {
|
|||
if (account.isPresent()) {
|
||||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature)
|
||||
return backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
|
||||
.thenCompose(backupManager::createTemporaryAttachmentUploadDescriptor)
|
||||
.thenApply(result -> new UploadDescriptorResponse(
|
||||
result.cdn(),
|
||||
|
@ -602,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))
|
||||
|
@ -620,7 +650,7 @@ public class ArchiveController {
|
|||
}
|
||||
|
||||
return Mono
|
||||
.fromFuture(backupManager.authenticateBackupUser(presentation.presentation, signature.signature))
|
||||
.fromFuture(backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent))
|
||||
.flatMap(backupUser -> backupManager.copyToBackup(backupUser, List.of(copyMediaRequest.toCopyParameters()))
|
||||
.next()
|
||||
.doOnNext(result -> backupMetrics.updateCopyCounter(result, UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
|
@ -701,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))
|
||||
|
@ -719,7 +749,7 @@ public class ArchiveController {
|
|||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
final Stream<CopyParameters> copyParams = copyMediaRequest.items().stream().map(CopyMediaRequest::toCopyParameters);
|
||||
return Mono.fromFuture(backupManager.authenticateBackupUser(presentation.presentation, signature.signature))
|
||||
return Mono.fromFuture(backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent))
|
||||
.flatMapMany(backupUser -> backupManager.copyToBackup(backupUser, copyParams.toList()))
|
||||
.doOnNext(result -> backupMetrics.updateCopyCounter(result, UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.map(CopyMediaBatchResponse.Entry::fromCopyResult)
|
||||
|
@ -740,7 +770,8 @@ 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))
|
||||
@NotNull
|
||||
|
@ -753,7 +784,7 @@ public class ArchiveController {
|
|||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
return backupManager
|
||||
.authenticateBackupUser(presentation.presentation, signature.signature)
|
||||
.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
|
||||
.thenCompose(backupManager::ttlRefresh)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
}
|
||||
|
@ -806,7 +837,8 @@ 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))
|
||||
@NotNull
|
||||
|
@ -825,7 +857,7 @@ public class ArchiveController {
|
|||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
return backupManager
|
||||
.authenticateBackupUser(presentation.presentation, signature.signature)
|
||||
.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
|
||||
.thenCompose(backupUser -> backupManager.list(backupUser, cursor, limit.orElse(1000))
|
||||
.thenApply(result -> new ListResponse(
|
||||
result.media()
|
||||
|
@ -861,7 +893,8 @@ 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))
|
||||
@NotNull
|
||||
|
@ -881,7 +914,7 @@ public class ArchiveController {
|
|||
.toList();
|
||||
|
||||
return backupManager
|
||||
.authenticateBackupUser(presentation.presentation, signature.signature)
|
||||
.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
|
||||
.thenCompose(authenticatedBackupUser -> backupManager
|
||||
.deleteMedia(authenticatedBackupUser, toDelete)
|
||||
.then().toFuture())
|
||||
|
@ -897,7 +930,8 @@ 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))
|
||||
@NotNull
|
||||
|
@ -910,7 +944,7 @@ public class ArchiveController {
|
|||
throw new BadRequestException("must not use authenticated connection for anonymous operations");
|
||||
}
|
||||
return backupManager
|
||||
.authenticateBackupUser(presentation.presentation, signature.signature)
|
||||
.authenticateBackupUser(presentation.presentation, signature.signature, userAgent)
|
||||
.thenCompose(backupManager::deleteEntireBackup)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,31 +15,27 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
|
||||
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Calling")
|
||||
@Path("/v2/calling")
|
||||
public class CallRoutingControllerV2 {
|
||||
|
||||
private static final Counter CLOUDFLARE_TURN_ERROR_COUNTER = Metrics.counter(name(CallRoutingControllerV2.class, "cloudflareTurnError"));
|
||||
private final RateLimiters rateLimiters;
|
||||
private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
|
||||
|
||||
private static final Counter CLOUDFLARE_TURN_ERROR_COUNTER =
|
||||
Metrics.counter(name(CallRoutingControllerV2.class, "cloudflareTurnError"));
|
||||
|
||||
public CallRoutingControllerV2(
|
||||
final RateLimiters rateLimiters,
|
||||
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager
|
||||
) {
|
||||
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager) {
|
||||
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager;
|
||||
}
|
||||
|
@ -58,25 +54,16 @@ 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
|
||||
) throws RateLimitExceededException, IOException {
|
||||
UUID aci = auth.getAccount().getUuid();
|
||||
rateLimiters.getCallEndpointLimiter().validate(aci);
|
||||
public GetCallingRelaysResponse getCallingRelays(final @Auth AuthenticatedDevice auth)
|
||||
throws RateLimitExceededException, IOException {
|
||||
|
||||
rateLimiters.getCallEndpointLimiter().validate(auth.accountIdentifier());
|
||||
|
||||
List<TurnToken> tokens = new ArrayList<>();
|
||||
try {
|
||||
tokens.add(cloudflareTurnCredentialsManager.retrieveFromCloudflare());
|
||||
} catch (Exception e) {
|
||||
CallRoutingControllerV2.CLOUDFLARE_TURN_ERROR_COUNTER.increment();
|
||||
return new GetCallingRelaysResponse(List.of(cloudflareTurnCredentialsManager.retrieveFromCloudflare()));
|
||||
} catch (final Exception e) {
|
||||
CLOUDFLARE_TURN_ERROR_COUNTER.increment();
|
||||
throw e;
|
||||
}
|
||||
|
||||
return new GetCallingRelaysResponse(tokens);
|
||||
}
|
||||
|
||||
public record GetCallingRelaysResponse(
|
||||
List<TurnToken> relays
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
@ -44,7 +43,6 @@ import java.util.EnumMap;
|
|||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
|
@ -52,12 +50,9 @@ import java.util.concurrent.CompletionStage;
|
|||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nullable;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
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;
|
||||
|
@ -74,6 +69,7 @@ import org.whispersystems.textsecuregcm.entities.TransferArchiveUploadedRequest;
|
|||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.DevicePlatformUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
|
@ -88,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")
|
||||
|
@ -153,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()))
|
||||
|
@ -167,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);
|
||||
}
|
||||
|
||||
|
@ -177,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
|
||||
*
|
||||
|
@ -208,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());
|
||||
|
||||
|
@ -225,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);
|
||||
}
|
||||
|
||||
|
@ -253,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())
|
||||
|
@ -280,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) {
|
||||
|
@ -352,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")
|
||||
|
@ -375,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
|
||||
|
@ -392,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));
|
||||
|
@ -402,7 +400,7 @@ public class DeviceController {
|
|||
|
||||
private AtomicInteger getCounterForLinkedDeviceListeners(final String userAgent) {
|
||||
try {
|
||||
return linkedDeviceListenersByPlatform.get(UserAgentUtil.parseUserAgentString(userAgent).getPlatform());
|
||||
return linkedDeviceListenersByPlatform.get(UserAgentUtil.parseUserAgentString(userAgent).platform());
|
||||
} catch (final UnrecognizedUserAgentException ignored) {
|
||||
return linkedDeviceListenersForUnrecognizedPlatforms;
|
||||
}
|
||||
|
@ -411,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)));
|
||||
}
|
||||
|
||||
|
@ -439,9 +438,10 @@ public class DeviceController {
|
|||
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) {
|
||||
|
@ -532,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(),
|
||||
.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()));
|
||||
transferArchiveUploadedRequest.transferArchive());
|
||||
});
|
||||
}
|
||||
|
||||
@GET
|
||||
|
@ -559,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")
|
||||
|
@ -576,49 +582,39 @@ 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));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private static io.micrometer.core.instrument.Tag primaryPlatformTag(final Account account) {
|
||||
final Device primaryDevice = account.getPrimaryDevice();
|
||||
|
||||
Optional<ClientPlatform> clientPlatform = Optional.empty();
|
||||
if (StringUtils.isNotBlank(primaryDevice.getGcmId())) {
|
||||
clientPlatform = Optional.of(ClientPlatform.ANDROID);
|
||||
} else if (StringUtils.isNotBlank(primaryDevice.getApnId())) {
|
||||
clientPlatform = Optional.of(ClientPlatform.IOS);
|
||||
}
|
||||
clientPlatform = clientPlatform.or(() -> Optional.ofNullable(
|
||||
switch (primaryDevice.getUserAgent()) {
|
||||
case "OWA" -> ClientPlatform.ANDROID;
|
||||
case "OWI", "OWP" -> ClientPlatform.IOS;
|
||||
case "OWD" -> ClientPlatform.DESKTOP;
|
||||
case null, default -> null;
|
||||
}));
|
||||
|
||||
return io.micrometer.core.instrument.Tag.of(
|
||||
"primaryPlatform",
|
||||
clientPlatform
|
||||
DevicePlatformUtil.getDevicePlatform(account.getPrimaryDevice())
|
||||
.map(p -> p.name().toLowerCase(Locale.ROOT))
|
||||
.orElse("unknown"));
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
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.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
@ -13,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;
|
||||
|
@ -31,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")
|
||||
|
@ -70,21 +73,40 @@ public class DonationController {
|
|||
@Path("/redeem-receipt")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
|
||||
@Operation(
|
||||
summary = "Redeem receipt",
|
||||
description = """
|
||||
Redeem a receipt acquired from /v1/subscription/{subscriberId}/receipt_credentials to add a badge to the
|
||||
account. After successful redemption, profile responses will include the corresponding badge (if configured as
|
||||
visible) until the expiration time on the receipt.
|
||||
""")
|
||||
@ApiResponse(responseCode = "200", description = "The receipt was redeemed")
|
||||
@ApiResponse(responseCode = "400", description = """
|
||||
The provided presentation or receipt was invalid, or the receipt was already redeemed for a different account. A
|
||||
specific error message suitable for logging will be included as text/plain body
|
||||
""")
|
||||
@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;
|
||||
try {
|
||||
receiptCredentialPresentation = receiptCredentialPresentationFactory.build(
|
||||
request.getReceiptCredentialPresentation());
|
||||
receiptCredentialPresentation = receiptCredentialPresentationFactory
|
||||
.build(request.getReceiptCredentialPresentation());
|
||||
} catch (InvalidInputException e) {
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("invalid receipt credential presentation").type(MediaType.TEXT_PLAIN_TYPE).build());
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
|
||||
.entity("invalid receipt credential presentation")
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build());
|
||||
}
|
||||
try {
|
||||
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
|
||||
} catch (VerificationFailedException e) {
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("receipt credential presentation verification failed").type(MediaType.TEXT_PLAIN_TYPE).build());
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
|
||||
.entity("receipt credential presentation verification failed")
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build());
|
||||
}
|
||||
|
||||
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
|
||||
|
@ -92,27 +114,35 @@ public class DonationController {
|
|||
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
|
||||
final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);
|
||||
if (badgeId == null) {
|
||||
return CompletableFuture.completedFuture(Response.serverError().entity("server does not recognize the requested receipt level").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 CompletableFuture.completedFuture(Response.serverError()
|
||||
.entity("server does not recognize the requested receipt level")
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build());
|
||||
}
|
||||
|
||||
return accountsManager.getByAccountIdentifierAsync(auth.getAccount().getUuid())
|
||||
.thenCompose(optionalAccount ->
|
||||
optionalAccount.map(account -> accountsManager.updateAsync(account, a -> {
|
||||
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());
|
||||
}
|
||||
|
||||
return accountsManager.updateAsync(account, a -> {
|
||||
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
|
||||
if (request.isPrimary()) {
|
||||
a.makeBadgePrimaryIfExists(clock, badgeId);
|
||||
}
|
||||
})).orElse(CompletableFuture.completedFuture(null)))
|
||||
})
|
||||
.thenApply(ignored -> Response.ok().build());
|
||||
});
|
||||
});
|
||||
}).thenCompose(Function.identity());
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import java.util.List;
|
||||
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
||||
|
||||
public record GetCallingRelaysResponse(List<TurnToken> relays) {
|
||||
}
|
|
@ -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");
|
||||
|
|
|
@ -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();
|
||||
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 -> throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, unwrapped);
|
||||
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) {
|
||||
|
|
|
@ -6,6 +6,7 @@ package org.whispersystems.textsecuregcm.controllers;
|
|||
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
|
@ -73,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")
|
||||
|
@ -88,6 +88,8 @@ public class KeysController {
|
|||
|
||||
private static final String GET_KEYS_COUNTER_NAME = MetricsUtil.name(KeysController.class, "getKeys");
|
||||
private static final String STORE_KEYS_COUNTER_NAME = MetricsUtil.name(KeysController.class, "storeKeys");
|
||||
private static final String STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME =
|
||||
MetricsUtil.name(KeysController.class, "storeKeyBundleSize");
|
||||
private static final String PRIMARY_DEVICE_TAG_NAME = "isPrimary";
|
||||
private static final String IDENTITY_TYPE_TAG_NAME = "identityType";
|
||||
private static final String KEY_TYPE_TAG_NAME = "keyType";
|
||||
|
@ -108,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) {
|
||||
|
||||
return accounts.getByAccountIdentifierAsync(auth.accountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final CompletableFuture<Integer> ecCountFuture =
|
||||
keysManager.getEcCount(auth.getAccount().getIdentifier(identityType), auth.getAuthenticatedDevice().getId());
|
||||
keysManager.getEcCount(account.getIdentifier(identityType), auth.deviceId());
|
||||
|
||||
final CompletableFuture<Integer> pqCountFuture =
|
||||
keysManager.getPqCount(auth.getAccount().getIdentifier(identityType), auth.getAuthenticatedDevice().getId());
|
||||
keysManager.getPqCount(account.getIdentifier(identityType), auth.deviceId());
|
||||
|
||||
return ecCountFuture.thenCombine(pqCountFuture, PreKeyCount::new);
|
||||
});
|
||||
}
|
||||
|
||||
@PUT
|
||||
|
@ -129,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)
|
||||
|
@ -140,22 +147,34 @@ 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();
|
||||
return accounts.getByAccountIdentifierAsync(auth.accountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.deviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final UUID identifier = account.getIdentifier(identityType);
|
||||
|
||||
checkSignedPreKeySignatures(setKeysRequest, account.getIdentityKey(identityType), userAgent);
|
||||
|
||||
final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);
|
||||
final Tag primaryDeviceTag = Tag.of(PRIMARY_DEVICE_TAG_NAME, String.valueOf(auth.getAuthenticatedDevice().isPrimary()));
|
||||
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());
|
||||
|
||||
final List<CompletableFuture<Void>> storeFutures = new ArrayList<>(4);
|
||||
|
||||
if (setKeysRequest.preKeys() != null && !setKeysRequest.preKeys().isEmpty()) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec")))
|
||||
.increment();
|
||||
if (!setKeysRequest.preKeys().isEmpty()) {
|
||||
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec"));
|
||||
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
|
||||
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.preKeys().size());
|
||||
|
||||
storeFutures.add(keysManager.storeEcOneTimePreKeys(identifier, device.getId(), setKeysRequest.preKeys()));
|
||||
}
|
||||
|
@ -168,10 +187,15 @@ public class KeysController {
|
|||
storeFutures.add(keysManager.storeEcSignedPreKeys(identifier, device.getId(), setKeysRequest.signedPreKey()));
|
||||
}
|
||||
|
||||
if (setKeysRequest.pqPreKeys() != null && !setKeysRequest.pqPreKeys().isEmpty()) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber")))
|
||||
.increment();
|
||||
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();
|
||||
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.pqPreKeys().size());
|
||||
|
||||
storeFutures.add(keysManager.storeKemOneTimePreKeys(identifier, device.getId(), setKeysRequest.pqPreKeys()));
|
||||
}
|
||||
|
@ -186,17 +210,14 @@ public class KeysController {
|
|||
|
||||
return CompletableFuture.allOf(storeFutures.toArray(EMPTY_FUTURE_ARRAY))
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
});
|
||||
}
|
||||
|
||||
private void checkSignedPreKeySignatures(final SetKeysRequest setKeysRequest,
|
||||
final IdentityKey identityKey,
|
||||
@Nullable final String userAgent) {
|
||||
|
||||
final List<SignedPreKey<?>> signedPreKeys = new ArrayList<>();
|
||||
|
||||
if (setKeysRequest.pqPreKeys() != null) {
|
||||
signedPreKeys.addAll(setKeysRequest.pqPreKeys());
|
||||
}
|
||||
final List<SignedPreKey<?>> signedPreKeys = new ArrayList<>(setKeysRequest.pqPreKeys());
|
||||
|
||||
if (setKeysRequest.pqLastResortPreKey() != null) {
|
||||
signedPreKeys.add(setKeysRequest.pqLastResortPreKey());
|
||||
|
@ -243,12 +264,15 @@ public class KeysController {
|
|||
""")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
||||
public CompletableFuture<Response> checkKeys(
|
||||
@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
@RequestBody @NotNull @Valid final CheckKeysRequest checkKeysRequest,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
@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 UUID identifier = account.getIdentifier(checkKeysRequest.identityType());
|
||||
final byte deviceId = auth.deviceId();
|
||||
|
||||
final CompletableFuture<Optional<ECSignedPreKey>> ecSignedPreKeyFuture =
|
||||
keysManager.getEcSignedPreKey(identifier, deviceId);
|
||||
|
@ -264,7 +288,7 @@ public class KeysController {
|
|||
final boolean digestsMatch;
|
||||
|
||||
if (maybeSignedPreKey.isPresent() && maybeLastResortKey.isPresent()) {
|
||||
final IdentityKey identityKey = auth.getAccount().getIdentityKey(checkKeysRequest.identityType());
|
||||
final IdentityKey identityKey = account.getIdentityKey(checkKeysRequest.identityType());
|
||||
final ECSignedPreKey ecSignedPreKey = maybeSignedPreKey.get();
|
||||
final KEMSignedPreKey lastResortKey = maybeLastResortKey.get();
|
||||
|
||||
|
@ -303,6 +327,7 @@ public class KeysController {
|
|||
|
||||
return Response.status(digestsMatch ? Response.Status.OK : Response.Status.CONFLICT).build();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@GET
|
||||
|
@ -318,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,
|
||||
|
||||
|
@ -331,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 {
|
||||
|
@ -355,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);
|
||||
}
|
||||
|
||||
|
@ -386,10 +414,7 @@ public class KeysController {
|
|||
.increment();
|
||||
|
||||
if (signedEcPreKey != null || unsignedEcPreKey != null || pqPreKey != null) {
|
||||
final int registrationId = switch (targetIdentifier.identityType()) {
|
||||
case ACI -> device.getRegistrationId();
|
||||
case PNI -> device.getPhoneNumberIdentityRegistrationId().orElse(device.getRegistrationId());
|
||||
};
|
||||
final int registrationId = device.getRegistrationId(targetIdentifier.identityType());
|
||||
|
||||
responseItems.add(
|
||||
new PreKeyResponseItem(device.getId(), registrationId, signedEcPreKey, unsignedEcPreKey,
|
||||
|
|
|
@ -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(),
|
||||
|
@ -436,11 +439,16 @@ public class MessageController {
|
|||
final Map<Byte, Integer> registrationIdsByDeviceId = messages.messages().stream()
|
||||
.collect(Collectors.toMap(IncomingMessage::destinationDeviceId, IncomingMessage::destinationRegistrationId));
|
||||
|
||||
final Optional<Byte> syncMessageSenderDeviceId = messageType == MessageType.SYNC
|
||||
? Optional.ofNullable(sender).map(AuthenticatedDevice::deviceId)
|
||||
: Optional.empty();
|
||||
|
||||
try {
|
||||
messageSender.sendMessages(destination,
|
||||
destinationIdentifier,
|
||||
messagesByDeviceId,
|
||||
registrationIdsByDeviceId,
|
||||
syncMessageSenderDeviceId,
|
||||
userAgent);
|
||||
} catch (final MismatchedDevicesException e) {
|
||||
if (!e.getMismatchedDevices().staleDeviceIds().isEmpty()) {
|
||||
|
@ -750,17 +758,23 @@ 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);
|
||||
|
||||
pushNotificationManager.handleMessagesRetrieved(account, device, userAgent);
|
||||
|
||||
return messagesManager.getMessagesForDevice(
|
||||
auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice(),
|
||||
auth.accountIdentifier(),
|
||||
device,
|
||||
false)
|
||||
.map(messagesAndHasMore -> {
|
||||
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
|
||||
|
@ -771,10 +785,15 @@ public class MessageController {
|
|||
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
|
||||
// ephemeral messages.
|
||||
false,
|
||||
userAgent,
|
||||
clientReleaseManager);
|
||||
})
|
||||
|
@ -785,15 +804,15 @@ public class MessageController {
|
|||
.record(estimateMessageListSizeBytes(messages));
|
||||
|
||||
if (!messages.messages().isEmpty()) {
|
||||
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.getAccount().getIdentifier(IdentityType.ACI),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
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);
|
||||
pushNotificationScheduler.scheduleDelayedNotification(account, device, NOTIFY_FOR_REMAINING_MESSAGES_DELAY);
|
||||
}
|
||||
|
||||
return messages;
|
||||
|
@ -801,6 +820,7 @@ public class MessageController {
|
|||
.timeout(Duration.ofSeconds(5))
|
||||
.subscribeOn(messageDeliveryScheduler)
|
||||
.toFuture();
|
||||
});
|
||||
}
|
||||
|
||||
private static long estimateMessageListSizeBytes(final OutgoingMessageEntityList messageList) {
|
||||
|
@ -817,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);
|
||||
|
@ -853,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,
|
||||
|
@ -889,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 =
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
||||
|
@ -428,7 +427,7 @@ public class OneTimeDonationController {
|
|||
@Nullable
|
||||
private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) {
|
||||
try {
|
||||
return UserAgentUtil.parseUserAgentString(userAgentString).getPlatform();
|
||||
return UserAgentUtil.parseUserAgentString(userAgentString).platform();
|
||||
} catch (final UnrecognizedUserAgentException e) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
@ -47,6 +48,7 @@ import java.util.concurrent.CompletableFuture;
|
|||
import java.util.concurrent.Executor;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nullable;
|
||||
import org.glassfish.jersey.server.ManagedAsync;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.ServiceId;
|
||||
|
@ -93,10 +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;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/profile")
|
||||
|
@ -115,14 +113,12 @@ public class ProfileController {
|
|||
private final ServerSecretParams serverSecretParams;
|
||||
private final ServerZkProfileOperations zkProfileOperations;
|
||||
|
||||
private final S3Client s3client;
|
||||
private final String bucket;
|
||||
|
||||
private final Executor batchIdentityCheckExecutor;
|
||||
|
||||
private static final String EXPIRING_PROFILE_KEY_CREDENTIAL_TYPE = "expiringProfileKey";
|
||||
|
||||
private static final String VERSION_NOT_FOUND_COUNTER_NAME = name(ProfileController.class, "versionNotFound");
|
||||
private static final String DUPLICATE_AUTHENTICATION_COUNTER_NAME = name(ProfileController.class, "duplicateAuthentication");
|
||||
|
||||
public ProfileController(
|
||||
Clock clock,
|
||||
|
@ -132,10 +128,8 @@ public class ProfileController {
|
|||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
ProfileBadgeConverter profileBadgeConverter,
|
||||
BadgesConfiguration badgesConfiguration,
|
||||
S3Client s3client,
|
||||
PostPolicyGenerator policyGenerator,
|
||||
PolicySigner policySigner,
|
||||
String bucket,
|
||||
ServerSecretParams serverSecretParams,
|
||||
ServerZkProfileOperations zkProfileOperations,
|
||||
Executor batchIdentityCheckExecutor) {
|
||||
|
@ -149,8 +143,6 @@ public class ProfileController {
|
|||
BadgeConfiguration::getId, Function.identity()));
|
||||
this.serverSecretParams = serverSecretParams;
|
||||
this.zkProfileOperations = zkProfileOperations;
|
||||
this.bucket = bucket;
|
||||
this.s3client = s3client;
|
||||
this.policyGenerator = policyGenerator;
|
||||
this.policySigner = policySigner;
|
||||
this.batchIdentityCheckExecutor = Preconditions.checkNotNull(batchIdentityCheckExecutor);
|
||||
|
@ -159,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();
|
||||
|
@ -186,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(),
|
||||
|
@ -198,17 +193,15 @@ public class ProfileController {
|
|||
request.commitment().serialize()));
|
||||
|
||||
if (request.getAvatarChange() != CreateProfileRequest.AvatarChange.UNCHANGED) {
|
||||
currentAvatar.ifPresent(s -> s3client.deleteObject(DeleteObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(s)
|
||||
.build()));
|
||||
currentAvatar.ifPresent(s -> profilesManager.deleteAvatar(s).join());
|
||||
}
|
||||
|
||||
final List<AccountBadge> updatedBadges = request.badges()
|
||||
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, auth.getAccount().getBadges()))
|
||||
.orElseGet(() -> auth.getAccount().getBadges());
|
||||
accountsManager.update(account, a -> {
|
||||
|
||||
final List<AccountBadge> updatedBadges = request.badges()
|
||||
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, a.getBadges()))
|
||||
.orElseGet(a::getBadges);
|
||||
|
||||
accountsManager.update(auth.getAccount(), a -> {
|
||||
a.setBadges(clock, updatedBadges);
|
||||
a.setCurrentProfileVersion(request.version());
|
||||
});
|
||||
|
@ -225,15 +218,20 @@ 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,
|
||||
@PathParam("version") String version)
|
||||
@PathParam("version") String version,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier);
|
||||
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,
|
||||
version,
|
||||
|
@ -246,21 +244,26 @@ 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,
|
||||
@PathParam("version") String version,
|
||||
@PathParam("credentialRequest") String credentialRequest,
|
||||
@QueryParam("credentialType") String credentialType)
|
||||
@QueryParam("credentialType") String credentialType,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
if (!EXPIRING_PROFILE_KEY_CREDENTIAL_TYPE.equals(credentialType)) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier);
|
||||
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);
|
||||
|
||||
return buildExpiringProfileKeyCredentialProfileResponse(targetAccount,
|
||||
|
@ -277,16 +280,18 @@ 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,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@PathParam("identifier") ServiceIdentifier identifier,
|
||||
@QueryParam("ca") boolean useCaCertificate)
|
||||
@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()) {
|
||||
|
@ -302,7 +307,7 @@ public class ProfileController {
|
|||
}
|
||||
} else {
|
||||
targetAccount = verifyPermissionToReceiveProfile(
|
||||
maybeRequester, accessKey.filter(ignored -> identifier.identityType() == IdentityType.ACI), identifier);
|
||||
maybeRequester, accessKey.filter(ignored -> identifier.identityType() == IdentityType.ACI), identifier, "getUnversionedProfile", userAgent);
|
||||
}
|
||||
return switch (identifier.identityType()) {
|
||||
case ACI -> buildBaseProfileResponseForAccountIdentity(targetAccount,
|
||||
|
@ -385,7 +390,7 @@ public class ProfileController {
|
|||
profileKeyCredentialResponse = ProfileHelper.getExpiringProfileKeyCredential(HexFormat.of().parseHex(encodedCredentialRequest),
|
||||
profile, new ServiceId.Aci(account.getUuid()), zkProfileOperations);
|
||||
} catch (VerificationFailedException | InvalidInputException e) {
|
||||
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST).build(), e);
|
||||
throw new BadRequestException(e);
|
||||
}
|
||||
return profileKeyCredentialResponse;
|
||||
})
|
||||
|
@ -473,7 +478,15 @@ public class ProfileController {
|
|||
*/
|
||||
private Account verifyPermissionToReceiveProfile(final Optional<Account> maybeRequester,
|
||||
final Optional<Anonymous> maybeAccessKey,
|
||||
final ServiceIdentifier accountIdentifier) throws RateLimitExceededException {
|
||||
final ServiceIdentifier accountIdentifier,
|
||||
final String endpoint,
|
||||
@Nullable final String userAgent) throws RateLimitExceededException {
|
||||
|
||||
if (maybeRequester.isPresent() && maybeAccessKey.isPresent()) {
|
||||
Metrics.counter(DUPLICATE_AUTHENTICATION_COUNTER_NAME,
|
||||
Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), io.micrometer.core.instrument.Tag.of("endpoint", endpoint)))
|
||||
.increment();
|
||||
}
|
||||
|
||||
if (maybeRequester.isPresent()) {
|
||||
rateLimiters.getProfileLimiter().validate(maybeRequester.get().getUuid());
|
||||
|
|
|
@ -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()));
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,6 @@ 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")
|
||||
@Tag(name = "Secure Value Recovery")
|
||||
|
@ -78,8 +77,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());
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -88,7 +88,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")
|
||||
|
@ -220,7 +219,7 @@ public class SubscriptionController {
|
|||
@Path("/{subscriberId}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
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);
|
||||
|
@ -232,7 +231,7 @@ public class SubscriptionController {
|
|||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
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 +247,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 +283,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 +322,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 +359,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,
|
||||
|
@ -432,7 +431,7 @@ public class SubscriptionController {
|
|||
@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.")
|
||||
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 =
|
||||
|
@ -473,7 +472,7 @@ public class SubscriptionController {
|
|||
@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.")
|
||||
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 =
|
||||
|
@ -627,7 +626,7 @@ 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")
|
||||
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);
|
||||
|
@ -662,7 +661,7 @@ public class SubscriptionController {
|
|||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
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 +690,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 =
|
||||
|
@ -755,7 +754,7 @@ public class SubscriptionController {
|
|||
@Nullable
|
||||
private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) {
|
||||
try {
|
||||
return UserAgentUtil.parseUserAgentString(userAgentString).getPlatform();
|
||||
return UserAgentUtil.parseUserAgentString(userAgentString).platform();
|
||||
} catch (final UnrecognizedUserAgentException e) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
|||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.Optional;
|
||||
|
@ -137,6 +138,7 @@ public class AccountAttributes {
|
|||
}
|
||||
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
public boolean isEachRegistrationIdValid() {
|
||||
return validRegistrationId(registrationId) && validRegistrationId(phoneNumberIdentityRegistrationId);
|
||||
}
|
||||
|
|
|
@ -7,30 +7,30 @@ package org.whispersystems.textsecuregcm.entities;
|
|||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.UUID;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||
|
||||
public record AccountIdentityResponse(
|
||||
@Schema(description="the account identifier for this account")
|
||||
@Schema(description = "the account identifier for this account")
|
||||
UUID uuid,
|
||||
|
||||
@Schema(description="the phone number associated with this account")
|
||||
@Schema(description = "the phone number associated with this account")
|
||||
String number,
|
||||
|
||||
@Schema(description="the account identifier for this account's phone-number identity")
|
||||
@Schema(description = "the account identifier for this account's phone-number identity")
|
||||
UUID pni,
|
||||
|
||||
@Schema(description="a hash of this account's username, if set")
|
||||
@Schema(description = "a hash of this account's username, if set")
|
||||
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
|
||||
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
|
||||
@Nullable byte[] usernameHash,
|
||||
|
||||
@Schema(description="this account's username link handle, if set")
|
||||
@Schema(description = "this account's username link handle, if set")
|
||||
@Nullable UUID usernameLinkHandle,
|
||||
|
||||
@Schema(description="whether any of this account's devices support storage")
|
||||
@Schema(description = "whether any of this account's devices support storage")
|
||||
boolean storageCapable,
|
||||
|
||||
@Schema(description = "entitlements for this account and their current expirations")
|
||||
|
|
|
@ -17,6 +17,7 @@ import javax.annotation.Nullable;
|
|||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
|
||||
|
@ -51,37 +52,31 @@ public record ChangeNumberRequest(
|
|||
arraySchema=@Schema(description="""
|
||||
A list of synchronization messages to send to companion devices to supply the private keysManager
|
||||
associated with the new identity key and their new prekeys.
|
||||
Exactly one message must be supplied for each enabled device other than the sending (primary) device."""))
|
||||
Exactly one message must be supplied for each device other than the sending (primary) device."""))
|
||||
@NotNull @Valid List<@NotNull @Valid IncomingMessage> deviceMessages,
|
||||
|
||||
@Schema(description="""
|
||||
A new signed elliptic-curve prekey for each enabled device on the account, including this one.
|
||||
A new signed elliptic-curve prekey for each device on the account, including this one.
|
||||
Each must be accompanied by a valid signature from the new identity key in this request.""")
|
||||
@NotNull @Valid Map<Byte, @NotNull @Valid ECSignedPreKey> devicePniSignedPrekeys,
|
||||
@NotNull @NotEmpty @Valid Map<Byte, @NotNull @Valid ECSignedPreKey> devicePniSignedPrekeys,
|
||||
|
||||
@Schema(description="""
|
||||
A new signed post-quantum last-resort prekey for each enabled device on the account, including this one.
|
||||
May be absent, in which case the last resort PQ prekeys for each device will be deleted if any had been stored.
|
||||
If present, must contain one prekey per enabled device including this one.
|
||||
Prekeys for devices that did not previously have any post-quantum prekeys stored will be silently dropped.
|
||||
A new signed post-quantum last-resort prekey for each device on the account, including this one.
|
||||
Each must be accompanied by a valid signature from the new identity key in this request.""")
|
||||
@Valid Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,
|
||||
@NotNull @NotEmpty @Valid Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,
|
||||
|
||||
@Schema(description="the new phone-number-identity registration ID for each enabled device on the account, including this one")
|
||||
@NotNull Map<Byte, Integer> pniRegistrationIds) implements PhoneVerificationRequest {
|
||||
@Schema(description="the new phone-number-identity registration ID for each device on the account, including this one")
|
||||
@NotNull @NotEmpty Map<Byte, Integer> pniRegistrationIds) implements PhoneVerificationRequest {
|
||||
|
||||
public boolean isSignatureValidOnEachSignedPreKey(@Nullable final String userAgent) {
|
||||
List<SignedPreKey<?>> spks = new ArrayList<>();
|
||||
if (devicePniSignedPrekeys != null) {
|
||||
spks.addAll(devicePniSignedPrekeys.values());
|
||||
}
|
||||
if (devicePniPqLastResortPrekeys != null) {
|
||||
final List<SignedPreKey<?>> spks = new ArrayList<>(devicePniSignedPrekeys.values());
|
||||
spks.addAll(devicePniPqLastResortPrekeys.values());
|
||||
}
|
||||
return spks.isEmpty() || PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, spks, userAgent, "change-number");
|
||||
|
||||
return PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, spks, userAgent, "change-number");
|
||||
}
|
||||
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
public boolean isEachPniRegistrationIdValid() {
|
||||
return pniRegistrationIds == null || pniRegistrationIds.values().stream().allMatch(RegistrationIdValidator::validRegistrationId);
|
||||
}
|
||||
|
|
|
@ -8,16 +8,16 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
|||
import com.google.protobuf.ByteString;
|
||||
import com.webauthn4j.converter.jackson.deserializer.json.ByteArrayBase64Deserializer;
|
||||
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,
|
||||
|
@ -34,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,
|
||||
|
@ -53,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());
|
||||
}
|
||||
|
||||
|
@ -69,6 +69,7 @@ public record IncomingMessage(int type,
|
|||
}
|
||||
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
public boolean isValidEnvelopeType() {
|
||||
if (type() == MessageProtos.Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE ||
|
||||
MessageProtos.Envelope.Type.forNumber(type()) == null) {
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
|
|||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Max;
|
||||
|
@ -46,6 +47,7 @@ public record IncomingMessageList(@NotNull
|
|||
}
|
||||
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
public boolean hasNoDuplicateRecipients() {
|
||||
final boolean valid = messages.stream()
|
||||
.filter(Objects::nonNull)
|
||||
|
|
|
@ -54,6 +54,7 @@ public record KeyTransparencySearchRequest(
|
|||
@Positive long distinguishedTreeHeadSize
|
||||
) {
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
public boolean isUnidentifiedAccessKeyProvidedWithE164() {
|
||||
return unidentifiedAccessKey.isPresent() == e164.isPresent();
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ public record LinkDeviceRequest(@Schema(requiredMode = Schema.RequiredMode.REQUI
|
|||
}
|
||||
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
public boolean hasExactlyOneMessageDeliveryChannel() {
|
||||
if (accountAttributes.getFetchesMessages()) {
|
||||
return deviceActivationRequest().apnToken().isEmpty() && deviceActivationRequest().gcmToken().isEmpty();
|
||||
|
|
|
@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.entities;
|
|||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.protobuf.ByteString;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
@ -37,7 +38,8 @@ public record OutgoingMessageEntity(UUID guid,
|
|||
boolean story,
|
||||
@Nullable byte[] reportSpamToken) {
|
||||
|
||||
public MessageProtos.Envelope toEnvelope() {
|
||||
@VisibleForTesting
|
||||
MessageProtos.Envelope toEnvelope() {
|
||||
final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()
|
||||
.setType(MessageProtos.Envelope.Type.forNumber(type()))
|
||||
.setClientTimestamp(timestamp())
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
|||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
@ -29,36 +30,36 @@ public record PhoneNumberIdentityKeyDistributionRequest(
|
|||
arraySchema=@Schema(description="""
|
||||
A list of synchronization messages to send to companion devices to supply the private keys
|
||||
associated with the new identity key and their new prekeys.
|
||||
Exactly one message must be supplied for each enabled device other than the sending (primary) device.
|
||||
Exactly one message must be supplied for each device other than the sending (primary) device.
|
||||
"""))
|
||||
List<@NotNull @Valid IncomingMessage> deviceMessages,
|
||||
|
||||
@NotNull
|
||||
@NotEmpty
|
||||
@Valid
|
||||
@Schema(description="""
|
||||
A new signed elliptic-curve prekey for each enabled device on the account, including this one.
|
||||
A new signed elliptic-curve prekey for each device on the account, including this one.
|
||||
Each must be accompanied by a valid signature from the new identity key in this request.""")
|
||||
Map<Byte, @NotNull @Valid ECSignedPreKey> devicePniSignedPrekeys,
|
||||
|
||||
@NotNull
|
||||
@NotEmpty
|
||||
@Valid
|
||||
@Schema(description="""
|
||||
A new signed post-quantum last-resort prekey for each enabled device on the account, including this one.
|
||||
May be absent, in which case the last resort PQ prekeys for each device will be deleted if any had been stored.
|
||||
If present, must contain one prekey per enabled device including this one.
|
||||
Prekeys for devices that did not previously have any post-quantum prekeys stored will be silently dropped.
|
||||
A new signed post-quantum last-resort prekey for each device on the account, including this one.
|
||||
Each must be accompanied by a valid signature from the new identity key in this request.""")
|
||||
@Valid Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,
|
||||
Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,
|
||||
|
||||
@NotNull
|
||||
@NotEmpty
|
||||
@Valid
|
||||
@Schema(description="The new registration ID to use for the phone-number identity of each device, including this one.")
|
||||
Map<Byte, Integer> pniRegistrationIds) {
|
||||
|
||||
public boolean isSignatureValidOnEachSignedPreKey(@Nullable final String userAgent) {
|
||||
List<SignedPreKey<?>> spks = new ArrayList<>(devicePniSignedPrekeys.values());
|
||||
if (devicePniPqLastResortPrekeys != null) {
|
||||
spks.addAll(devicePniPqLastResortPrekeys.values());
|
||||
}
|
||||
return spks.isEmpty() || PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, spks, userAgent, "distribute-pni-keys");
|
||||
}
|
||||
final List<SignedPreKey<?>> signedPreKeys = new ArrayList<>(devicePniSignedPrekeys.values());
|
||||
signedPreKeys.addAll(devicePniPqLastResortPrekeys.values());
|
||||
|
||||
return PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, signedPreKeys, userAgent, "distribute-pni-keys");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.entities;
|
|||
|
||||
import static org.apache.commons.lang3.StringUtils.isNotBlank;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.ws.rs.ClientErrorException;
|
||||
import java.util.Base64;
|
||||
|
@ -25,6 +26,7 @@ public interface PhoneVerificationRequest {
|
|||
|
||||
// for the @AssertTrue to work with bean validation, method name must follow 'isSmth()'/'getSmth()' naming convention
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
default boolean isValid() {
|
||||
// checking that exactly one of sessionId/recoveryPassword is non-empty
|
||||
return isNotBlank(sessionId()) ^ (recoveryPassword() != null && recoveryPassword().length > 0);
|
||||
|
|
|
@ -7,12 +7,16 @@ package org.whispersystems.textsecuregcm.entities;
|
|||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
|
||||
public class RedeemReceiptRequest {
|
||||
|
||||
@Schema(description = "Presentation of a ZK receipt encoded in standard padded base64", implementation = String.class)
|
||||
private final byte[] receiptCredentialPresentation;
|
||||
@Schema(description = "If true, the corresponding badge should be visible on the profile")
|
||||
private final boolean visible;
|
||||
@Schema(description = "if true, and the new badge is visible, it should be the primary badge on the profile")
|
||||
private final boolean primary;
|
||||
|
||||
@JsonCreator
|
||||
|
|
|
@ -111,6 +111,7 @@ public record RegistrationRequest(@Schema(requiredMode = Schema.RequiredMode.NOT
|
|||
|
||||
@VisibleForTesting
|
||||
@AssertTrue
|
||||
@Schema(hidden = true)
|
||||
boolean hasExactlyOneMessageDeliveryChannel() {
|
||||
if (accountAttributes.getFetchesMessages()) {
|
||||
return deviceActivationRequest().apnToken().isEmpty() && deviceActivationRequest().gcmToken().isEmpty();
|
||||
|
|
|
@ -5,11 +5,15 @@
|
|||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
|
||||
public record SetKeysRequest(
|
||||
@NotNull
|
||||
@Valid
|
||||
@Size(max=100)
|
||||
@Schema(description = """
|
||||
A list of unsigned elliptic-curve prekeys to use for this device. If present and not empty, replaces all stored
|
||||
unsigned EC prekeys for the device; if absent or empty, any stored unsigned EC prekeys for the device are not
|
||||
|
@ -25,7 +29,9 @@ public record SetKeysRequest(
|
|||
""")
|
||||
ECSignedPreKey signedPreKey,
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@Size(max=100)
|
||||
@Schema(description = """
|
||||
A list of signed post-quantum one-time prekeys to use for this device. Each key must have a valid signature from
|
||||
the identity key in this request. If present and not empty, replaces all stored unsigned PQ prekeys for the
|
||||
|
@ -40,4 +46,16 @@ public record SetKeysRequest(
|
|||
deleted. If present, must have a valid signature from the identity key in this request.
|
||||
""")
|
||||
KEMSignedPreKey pqLastResortPreKey) {
|
||||
public SetKeysRequest {
|
||||
// It’s a little counter-intuitive, but this compact constructor allows a default value
|
||||
// to be used when one isn’t specified, allowing the field to still be
|
||||
// validated as @NotNull
|
||||
if (preKeys == null) {
|
||||
preKeys = List.of();
|
||||
}
|
||||
|
||||
if (pqPreKeys == null) {
|
||||
pqPreKeys = List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ import java.util.Optional;
|
|||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicE164ExperimentEnrollmentConfiguration;
|
||||
|
@ -19,18 +21,18 @@ import org.whispersystems.textsecuregcm.util.Util;
|
|||
public class ExperimentEnrollmentManager {
|
||||
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
private final Random random;
|
||||
private final Supplier<Random> random;
|
||||
|
||||
|
||||
public ExperimentEnrollmentManager(
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this(dynamicConfigurationManager, ThreadLocalRandom.current());
|
||||
this(dynamicConfigurationManager, ThreadLocalRandom::current);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
ExperimentEnrollmentManager(
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
final Random random) {
|
||||
final Supplier<Random> random) {
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.random = random;
|
||||
}
|
||||
|
@ -50,7 +52,7 @@ public class ExperimentEnrollmentManager {
|
|||
return Optional.of(false);
|
||||
}
|
||||
if (config.getUuidSelector().getUuids().contains(accountUuid)) {
|
||||
final int r = random.nextInt(100);
|
||||
final int r = random.get().nextInt(100);
|
||||
return Optional.of(r < config.getUuidSelector().getUuidEnrollmentPercentage());
|
||||
}
|
||||
|
||||
|
|
|
@ -81,7 +81,16 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor {
|
|||
final Metadata headers,
|
||||
final ServerCallHandler<ReqT, RespT> next) {
|
||||
|
||||
if (shouldBlock(RequestAttributesUtil.getUserAgent().orElse(null))) {
|
||||
@Nullable final UserAgent userAgent = RequestAttributesUtil.getUserAgent()
|
||||
.map(userAgentString -> {
|
||||
try {
|
||||
return UserAgentUtil.parseUserAgentString(userAgentString);
|
||||
} catch (final UnrecognizedUserAgentException e) {
|
||||
return null;
|
||||
}
|
||||
}).orElse(null);
|
||||
|
||||
if (shouldBlock(userAgent)) {
|
||||
call.close(StatusConstants.UPGRADE_NEEDED_STATUS, new Metadata());
|
||||
return new ServerCall.Listener<>() {};
|
||||
} else {
|
||||
|
@ -108,28 +117,28 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (blockedVersionsByPlatform.containsKey(userAgent.getPlatform())) {
|
||||
if (blockedVersionsByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) {
|
||||
if (blockedVersionsByPlatform.containsKey(userAgent.platform())) {
|
||||
if (blockedVersionsByPlatform.get(userAgent.platform()).contains(userAgent.version())) {
|
||||
recordDeprecation(userAgent, BLOCKED_CLIENT_REASON);
|
||||
shouldBlock = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (minimumVersionsByPlatform.containsKey(userAgent.getPlatform())) {
|
||||
if (userAgent.getVersion().isLowerThan(minimumVersionsByPlatform.get(userAgent.getPlatform()))) {
|
||||
if (minimumVersionsByPlatform.containsKey(userAgent.platform())) {
|
||||
if (userAgent.version().isLowerThan(minimumVersionsByPlatform.get(userAgent.platform()))) {
|
||||
recordDeprecation(userAgent, EXPIRED_CLIENT_REASON);
|
||||
shouldBlock = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (versionsPendingBlockByPlatform.containsKey(userAgent.getPlatform())) {
|
||||
if (versionsPendingBlockByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) {
|
||||
if (versionsPendingBlockByPlatform.containsKey(userAgent.platform())) {
|
||||
if (versionsPendingBlockByPlatform.get(userAgent.platform()).contains(userAgent.version())) {
|
||||
recordPendingDeprecation(userAgent, BLOCKED_CLIENT_REASON);
|
||||
}
|
||||
}
|
||||
|
||||
if (versionsPendingDeprecationByPlatform.containsKey(userAgent.getPlatform())) {
|
||||
if (userAgent.getVersion().isLowerThan(versionsPendingDeprecationByPlatform.get(userAgent.getPlatform()))) {
|
||||
if (versionsPendingDeprecationByPlatform.containsKey(userAgent.platform())) {
|
||||
if (userAgent.version().isLowerThan(versionsPendingDeprecationByPlatform.get(userAgent.platform()))) {
|
||||
recordPendingDeprecation(userAgent, EXPIRED_CLIENT_REASON);
|
||||
}
|
||||
}
|
||||
|
@ -139,13 +148,13 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor {
|
|||
|
||||
private void recordDeprecation(final UserAgent userAgent, final String reason) {
|
||||
Metrics.counter(DEPRECATED_CLIENT_COUNTER_NAME,
|
||||
PLATFORM_TAG, userAgent != null ? userAgent.getPlatform().name().toLowerCase() : "unrecognized",
|
||||
PLATFORM_TAG, userAgent != null ? userAgent.platform().name().toLowerCase() : "unrecognized",
|
||||
REASON_TAG_NAME, reason).increment();
|
||||
}
|
||||
|
||||
private void recordPendingDeprecation(final UserAgent userAgent, final String reason) {
|
||||
Metrics.counter(PENDING_DEPRECATION_COUNTER_NAME,
|
||||
PLATFORM_TAG, userAgent.getPlatform().name().toLowerCase(),
|
||||
PLATFORM_TAG, userAgent.platform().name().toLowerCase(),
|
||||
REASON_TAG_NAME, reason).increment();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.filters;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import com.vdurmont.semver4j.Semver;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
|
@ -14,13 +15,14 @@ import jakarta.ws.rs.container.ContainerRequestContext;
|
|||
import jakarta.ws.rs.container.ContainerRequestFilter;
|
||||
import jakarta.ws.rs.core.SecurityContext;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Supplier;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRestDeprecationConfiguration.PlatformConfiguration;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
|
@ -31,51 +33,48 @@ import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
|||
|
||||
public class RestDeprecationFilter implements ContainerRequestFilter {
|
||||
|
||||
private static final String EXPERIMENT_NAME = "restDeprecation";
|
||||
private static final String AUTHENTICATED_EXPERIMENT_NAME = "restDeprecation";
|
||||
private static final String DEPRECATED_REST_COUNTER_NAME = MetricsUtil.name(RestDeprecationFilter.class, "blockedRestRequest");
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RestDeprecationFilter.class);
|
||||
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
final ExperimentEnrollmentManager experimentEnrollmentManager;
|
||||
final Supplier<Random> random;
|
||||
|
||||
public RestDeprecationFilter(
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
final ExperimentEnrollmentManager experimentEnrollmentManager) {
|
||||
this(dynamicConfigurationManager, experimentEnrollmentManager, ThreadLocalRandom::current);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public RestDeprecationFilter(
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
final ExperimentEnrollmentManager experimentEnrollmentManager,
|
||||
final Supplier<Random> random) {
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void filter(final ContainerRequestContext requestContext) throws IOException {
|
||||
|
||||
final SecurityContext securityContext = requestContext.getSecurityContext();
|
||||
|
||||
if (securityContext == null || securityContext.getUserPrincipal() == null) {
|
||||
// We can't check if an unauthenticated request is in the experiment
|
||||
return;
|
||||
}
|
||||
|
||||
if (securityContext.getUserPrincipal() instanceof AuthenticatedDevice ad) {
|
||||
if (!experimentEnrollmentManager.isEnrolled(ad.getAccount().getUuid(), EXPERIMENT_NAME)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
log.error("Security context was not null but user principal was of type {}", securityContext.getUserPrincipal().getClass().getName());
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<ClientPlatform, Semver> minimumRestFreeVersion = dynamicConfigurationManager.getConfiguration().minimumRestFreeVersion();
|
||||
final String userAgentString = requestContext.getHeaderString(HttpHeaders.USER_AGENT);
|
||||
|
||||
try {
|
||||
final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString);
|
||||
final ClientPlatform platform = userAgent.getPlatform();
|
||||
final Semver version = userAgent.getVersion();
|
||||
if (!minimumRestFreeVersion.containsKey(platform)) {
|
||||
final ClientPlatform platform = userAgent.platform();
|
||||
final Semver version = userAgent.version();
|
||||
final PlatformConfiguration config = dynamicConfigurationManager.getConfiguration().restDeprecation().platforms().get(platform);
|
||||
if (config == null) {
|
||||
return;
|
||||
}
|
||||
if (version.isGreaterThanOrEqualTo(minimumRestFreeVersion.get(platform))) {
|
||||
if (!isEnrolled(requestContext, config.universalRolloutPercent())) {
|
||||
return;
|
||||
}
|
||||
if (version.isGreaterThanOrEqualTo(config.minimumRestFreeVersion())) {
|
||||
Metrics.counter(
|
||||
DEPRECATED_REST_COUNTER_NAME, Tags.of("platform", platform.name().toLowerCase(), "version", version.toString()))
|
||||
.increment();
|
||||
|
@ -85,4 +84,23 @@ public class RestDeprecationFilter implements ContainerRequestFilter {
|
|||
return; // at present we're only interested in experimenting on known clients
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEnrolled(final ContainerRequestContext requestContext, int universalRolloutPercent) {
|
||||
if (random.get().nextInt(100) < universalRolloutPercent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final SecurityContext securityContext = requestContext.getSecurityContext();
|
||||
|
||||
if (securityContext == null || securityContext.getUserPrincipal() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -206,7 +206,8 @@ public class BackupsAnonymousGrpcService extends ReactorBackupsAnonymousGrpc.Bac
|
|||
try {
|
||||
return backupManager.authenticateBackupUser(
|
||||
new BackupAuthCredentialPresentation(signedPresentation.getPresentation().toByteArray()),
|
||||
signedPresentation.getPresentationSignature().toByteArray());
|
||||
signedPresentation.getPresentationSignature().toByteArray(),
|
||||
RequestAttributesUtil.getUserAgent().orElse(null));
|
||||
} catch (InvalidInputException e) {
|
||||
throw Status.UNAUTHENTICATED.withDescription("Could not deserialize presentation").asRuntimeException();
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
/**
|
||||
* Indicates that a remote channel was not found for a given server call or remote address.
|
||||
*/
|
||||
public class ChannelNotFoundException extends Exception {
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import io.grpc.Context;
|
||||
import io.grpc.ForwardingServerCallListener;
|
||||
import io.grpc.Grpc;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import io.netty.channel.local.LocalAddress;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
|
||||
/**
|
||||
* Then channel shutdown interceptor rejects new requests if a channel is shutting down and works in tandem with
|
||||
* {@link GrpcClientConnectionManager} to maintain an active call count for each channel otherwise.
|
||||
*/
|
||||
public class ChannelShutdownInterceptor implements ServerInterceptor {
|
||||
|
||||
private final GrpcClientConnectionManager grpcClientConnectionManager;
|
||||
|
||||
public ChannelShutdownInterceptor(final GrpcClientConnectionManager grpcClientConnectionManager) {
|
||||
this.grpcClientConnectionManager = grpcClientConnectionManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
|
||||
final Metadata headers,
|
||||
final ServerCallHandler<ReqT, RespT> next) {
|
||||
|
||||
if (!grpcClientConnectionManager.handleServerCallStart(call)) {
|
||||
// Don't allow new calls if the connection is getting ready to close
|
||||
return ServerInterceptorUtil.closeWithStatus(call, Status.UNAVAILABLE);
|
||||
}
|
||||
|
||||
return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(next.startCall(call, headers)) {
|
||||
@Override
|
||||
public void onComplete() {
|
||||
grpcClientConnectionManager.handleServerCallComplete(call);
|
||||
super.onComplete();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel() {
|
||||
grpcClientConnectionManager.handleServerCallComplete(call);
|
||||
super.onCancel();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@ public class DeviceCapabilityUtil {
|
|||
case DEVICE_CAPABILITY_DELETE_SYNC -> DeviceCapability.DELETE_SYNC;
|
||||
case DEVICE_CAPABILITY_STORAGE_SERVICE_RECORD_KEY_ROTATION -> DeviceCapability.STORAGE_SERVICE_RECORD_KEY_ROTATION;
|
||||
case DEVICE_CAPABILITY_ATTACHMENT_BACKFILL -> DeviceCapability.ATTACHMENT_BACKFILL;
|
||||
case DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET -> DeviceCapability.SPARSE_POST_QUANTUM_RATCHET;
|
||||
case DEVICE_CAPABILITY_UNSPECIFIED, UNRECOGNIZED -> throw Status.INVALID_ARGUMENT.withDescription("Unrecognized device capability").asRuntimeException();
|
||||
};
|
||||
}
|
||||
|
@ -31,6 +32,7 @@ public class DeviceCapabilityUtil {
|
|||
case DELETE_SYNC -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_DELETE_SYNC;
|
||||
case STORAGE_SERVICE_RECORD_KEY_ROTATION -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_STORAGE_SERVICE_RECORD_KEY_ROTATION;
|
||||
case ATTACHMENT_BACKFILL -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_ATTACHMENT_BACKFILL;
|
||||
case SPARSE_POST_QUANTUM_RATCHET -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,8 +10,12 @@ import org.whispersystems.textsecuregcm.storage.Device;
|
|||
|
||||
public class DeviceIdUtil {
|
||||
|
||||
public static boolean isValid(int deviceId) {
|
||||
return deviceId >= Device.PRIMARY_ID && deviceId <= Byte.MAX_VALUE;
|
||||
}
|
||||
|
||||
static byte validate(int deviceId) {
|
||||
if (deviceId < Device.PRIMARY_ID || deviceId > Byte.MAX_VALUE) {
|
||||
if (!isValid(deviceId)) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Device ID is out of range").asRuntimeException();
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue