Compare commits

..

130 Commits

Author SHA1 Message Date
Ameya Lokare f4698dd5b2 Update to the latest version of the spam filter 2025-06-27 12:07:45 -07:00
Adel Lahlou d4322a2ed4 Remove latency based 1:1 call routing 2025-06-27 12:06:43 -07:00
Jon Chambers 7260a9d5b4 Make FoundationDB versions available at runtime 2025-06-27 11:21:50 -04:00
Jon Chambers 12b4ceb4aa Configure FoundationDB service container's database via Docker, removing `fdbcli` dependency 2025-06-27 11:08:58 -04:00
Jon Chambers fa1cd5c263 Install the Maven-fetched FoundationDB client library on GitHub Actions runner 2025-06-27 11:06:04 -04:00
Jon Chambers f8da13912d Fetch the FoundationDB client library as a pre-package step rather than including it in version control 2025-06-27 11:04:53 -04:00
Jon Chambers a3b3bf86ba Add a note about the FoundationDB client library requirement to the README 2025-06-27 11:04:52 -04:00
Jon Chambers a99f7bb87d Add test dependencies for FoundationDB 2025-06-27 11:04:52 -04:00
Jon Chambers d6f14d02dd Add a FoundationDB service container for tests 2025-06-27 11:04:46 -04:00
Jon Chambers d18671eaf9 Add FoundationDB runtime dependencies 2025-06-26 12:13:09 -04:00
Jon Chambers 87c30d00e8 Store compressed envelopes at rest 2025-06-25 15:20:19 -04:00
Jon Chambers c8f45685b8 Expand envelopes on load from storage 2025-06-25 14:31:19 -04:00
Jon Chambers bb90d80d22 Add a utility for compressing/expanding envelopes 2025-06-25 14:31:19 -04:00
Jon Chambers dcc541f86e Add binary representation fields for service IDs/UUIDs 2025-06-25 14:31:19 -04:00
Ravi Khadiwala aaa36fd8f5 Add a crawler for orphaned prekey pages 2025-06-24 13:46:48 -05:00
Ravi Khadiwala 2bb14892af Add paged prekey store 2025-06-24 13:46:48 -05:00
Ameya Lokare 6d8701665e Update to the latest version of the spam filter 2025-06-24 11:46:11 -07:00
Katherine c2b8fdac0d
Only log for an unexpected error from the key transparency service 2025-06-24 14:45:53 -04:00
Katherine 059caa4c57
Implement key transparency endpoints using `simple-grpc` 2025-06-24 14:01:35 -04:00
Jon Chambers 51773f5709 Update to the latest version of the spam filter 2025-06-23 10:20:24 -04:00
Jon Chambers 483404a67f
Retire authenticated device getters 2025-06-23 10:10:30 -04:00
Jon Chambers 68b84dd56b Remove the PQ key check from `IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter`
All devices now have PQ keys
2025-06-23 08:58:15 -05:00
Jon Chambers 7709e1313c Update to the latest version of the spam filter 2025-06-23 09:50:45 -04:00
Jon Chambers c952baa672
Don't cache authenticated accounts in memory 2025-06-23 09:40:05 -04:00
Ravi Khadiwala 9dfe51eac4 Forbid linked devices from setting backup-ids 2025-06-18 11:07:52 -05:00
andrew-signal 5de848bf38
Instrument request/response sizes 2025-06-17 11:16:57 -04:00
Ravi Khadiwala 295cedc075 remove experiment configuration for low urgency pushes 2025-06-17 09:43:35 -05:00
Jon Chambers 4f1cab407f Simplify WebSocket authentication failure handling 2025-06-17 10:41:29 -04:00
Ravi Khadiwala 626a7fdad7 Add docs to /v1/donations/redeem-receipt 2025-06-12 17:07:19 -05:00
Jon Chambers 9a1da23bdb Add an `isEphemeral` dimension to message delivery latency metrics 2025-06-10 17:05:46 -05:00
ravi-signal 4ffd164461
Wire up the direct noise tunnel 2025-06-10 16:56:31 -05:00
Jon Chambers 904cc63a72 Clarify that `OutgoingMessageEntity#toEnvelope` is a test-only method 2025-06-10 16:55:13 -05:00
Ravi Khadiwala 177c36b0d6 Fix backup metric names and use remote aggregation 2025-06-10 16:54:45 -05:00
Jon Chambers 5fc6bdd478 Add a device capability for sparse post-quantum ratchet (SPQR) 2025-06-10 16:54:30 -05:00
Jon Chambers ca6e5fb0a8 Hide model validation methods in API docs 2025-06-06 16:19:48 -04:00
Jon Chambers 1a7a446150
Regenerate phone number identifiers when regenerating secondary table data 2025-06-05 15:12:33 -04:00
Ameya Lokare 981d929f50 Extend ChannelCircuitBreakerHandler with ChannelOutboundHandlerAdapter
instead of ChannelDuplexHandler
2025-06-05 12:00:21 -05:00
Ravi Khadiwala 4a3eb642c0 Remove unused S3Client 2025-06-05 11:49:03 -05:00
Ameya Lokare a1b0c1a4aa Update to the latest version of the spam filter 2025-06-04 10:53:16 -07:00
Chris Eager 0f185a528d Add `isUrgent` tag to message delivery latency metrics 2025-06-04 10:51:05 -07:00
Ravi Khadiwala aef7f3fef8 Avoid generating invalid deviceId in unit test 2025-06-04 12:49:23 -05:00
Ravi Khadiwala 1767586797 Add metrics for opk upload size 2025-06-04 10:46:11 -07:00
Ameya Lokare 60be6de9af Trivial: Add missing `@Mutable` annotation to setPublicKey 2025-06-03 18:13:39 -07:00
Jonathan Klabunde Tomer 2a7551cca5
support REST deprecation by platform for all requests with % rollout 2025-05-29 16:15:19 -07:00
Jonathan Klabunde Tomer 36439b5252
call ThreadLocalRandom.current at point of use only 2025-05-29 16:15:05 -07:00
ravi-signal bbee80dbd0
Fix class cast exceptions with SchedulingUtil 2025-05-29 16:14:23 -07:00
Ravi Khadiwala a7ea42adc3 Add a crawler to recalculate quota usage 2025-05-28 15:49:55 -05:00
Ravi Khadiwala 4dc3b19d2a Track backup metrics on refreshes 2025-05-28 15:28:55 -05:00
ravi-signal 030d8e8dd4
Reduce drift between tracked and actual backup usage 2025-05-28 15:25:32 -05:00
Chris Eager 401165d0d6 Convert unidentifiedDelivery.certificate configuration to byte[] 2025-05-27 14:55:14 -05:00
Chris Eager ccb209ad37 Consolidate avatar deletion logic in ProfilesManager 2025-05-27 13:46:41 -05:00
Chris Eager c1a66e0418 Delete avatars in ProfilesManager#deleteAll 2025-05-27 13:46:41 -05:00
Jon Chambers 8491d18413 Revert "Count API calls by authentication status"
This reverts commit 9b835633ab.
2025-05-27 13:51:17 -04:00
Jon Chambers 9b835633ab
Count API calls by authentication status 2025-05-27 11:59:28 -04:00
Jon Chambers fbbc4b8b27 Get integration test configuration directly from a GitHub Actions variable 2025-05-21 14:42:14 -04:00
Jonathan Klabunde Tomer 74ee1c8c4f Update to the latest version of the spam filter 2025-05-21 10:46:18 -07:00
Jonathan Klabunde Tomer 35604cf151
Simplify rate limiters by making them all dynamic 2025-05-21 10:29:26 -07:00
Ravi Khadiwala aafcd63a9f Decrease the page size for OPK queries
A single element is almost always enough
2025-05-20 11:21:20 -04:00
Jon Chambers 43a534f05b Add a command for regenerating account constraint tables 2025-05-20 11:21:02 -04:00
Jon Chambers 9ec66dac7f Make `getRegistrationId` identity-type-aware 2025-05-14 14:39:11 -04:00
Jon Chambers 13fc0ffbca Assume that PNI registration IDs are always present on `Device` records 2025-05-14 14:39:11 -04:00
Jon Chambers 93ba6616d1 Perform device list validations in the scope of a pessimistic account lock 2025-05-14 14:39:11 -04:00
Jon Chambers a4b98f38a6 Use a `Callable` for tasks performed within the scope of a pessimistic lock 2025-05-14 14:39:11 -04:00
Jon Chambers b95d08aaea Drop `PqKeysUtil` 2025-05-14 14:39:11 -04:00
Jon Chambers b400d49e77 Require PQ keys when changing numbers or distributing key material 2025-05-14 14:39:11 -04:00
Jon Chambers e43487155f Remove commands for removing accounts/devices without PQ or PNI key material 2025-05-14 14:39:11 -04:00
Jon Chambers dee3723d97 Remove an unused user-agent argument 2025-05-14 14:39:11 -04:00
Jon Chambers b7e986f43c Add an integration test for changing phone numbers 2025-05-14 14:39:11 -04:00
Jon Chambers 664fb23e97 Resolve warnings/suggestions throughout `AccountsTest` 2025-05-14 11:30:59 -04:00
Chris Eager 714ef128a1
Compare using PNI in account reclamation 2025-05-13 16:41:42 -07:00
Ravi Khadiwala 7cf3fce624 Log unexpected account reclaim mismatches 2025-05-13 14:17:18 -05:00
ravi-signal 0cc5431867
Update noise-gRPC protocol errors 2025-05-13 14:16:23 -05:00
Ravi Khadiwala b8d5b2c8ea Match account idle duration in RemoveExpiredBackupsCommand 2025-05-13 14:15:50 -05:00
Ravi Khadiwala 894ca6d290 remove ANDROID_SKIP_LOW_URGENCY_PUSH_EXPERIMENT 2025-05-13 13:59:28 -05:00
Ravi Khadiwala 847b25f695 Add experiment to coalesce android notifications 2025-05-13 13:59:28 -05:00
Ravi Khadiwala 703a05cb15 Support scheduling background FCMs 2025-05-13 13:59:28 -05:00
Jon Chambers 30c194c557
Exclude `RateLimitExceededException` from fail-open checks 2025-05-12 15:24:57 -07:00
Jonathan Klabunde Tomer cc7b030a41
Send disconnection requests after non-API device unlinks 2025-05-06 13:36:41 -07:00
Jon Chambers 7a91c4d5b7 Correct metric names 2025-05-05 13:53:22 -04:00
Jon Chambers 287da6e7e3 Ignore already-locked accounts in PNI key cleanup operations 2025-05-05 13:53:22 -04:00
Katherine 7cf89764e7
Update `FullTreeHead` to use `FullAuditorTreeHead` 2025-05-05 10:44:57 -07:00
Jon Chambers d316c72beb
Add commands for removing accounts/devices without PNI key material 2025-05-05 12:10:47 -04:00
Katherine Yen 82d187cc45 Update key transparency protobufs 2025-05-02 10:40:53 -04:00
Jon Chambers 0c240d21d2 Update to the latest version of the spam filter 2025-05-02 10:40:07 -04:00
Jon Chambers 009252c831 Configure IP-keyed rate limiters to fail open 2025-05-02 10:30:29 -04:00
Jon Chambers 0c1146aaa5 Configure rate limiters with large initial capacities to fail open 2025-05-02 10:30:29 -04:00
Jon Chambers 4fd06594a0 Configure fast-regenerating rate limiters to fail open 2025-05-02 10:30:29 -04:00
Jon Chambers 4e175be88f Allow the "inbound message bytes" limiter to fail open 2025-05-02 10:30:29 -04:00
Jon Chambers 771a700acd Configure fail-open policy on individual rate limiters 2025-05-02 10:30:29 -04:00
Jon Chambers e9bd5da2c3 Allow fail-open behavior for a wider range of exceptions 2025-05-02 10:30:29 -04:00
Jon Chambers f64244f33a Remove an unused TURN rate limiter 2025-05-02 10:30:29 -04:00
Ravi Khadiwala ed1417c3e3 Update to the latest version of the spam filter 2025-04-30 15:06:03 -05:00
ravi-signal 0398e02690
Add NoiseDirect framing protocol 2025-04-30 15:05:05 -05:00
Chris Eager e285bf1a52 Fix test by using generic `exists` command 2025-04-29 13:05:10 -05:00
Ameya Lokare 2c9219d4f7 Update to the latest version of the spam filter 2025-04-29 10:57:05 -07:00
Jon Chambers 26b3b75054 Only fetch last-resort PQ keys for accounts with linked devices 2025-04-28 16:59:08 -04:00
Jon Chambers cdb651b68f
Add commands for removing devices without PQ keys 2025-04-28 15:45:27 -04:00
Ameya Lokare 91a36f4421 Update to the latest version of the spam filter 2025-04-28 11:59:43 -07:00
Jonathan Klabunde Tomer 21c1d71551 take advantage of list non-nullitude 2025-04-25 10:06:42 -05:00
Jonathan Klabunde Tomer 38befdb260 default lists to empty 2025-04-25 10:06:42 -05:00
Jonathan Klabunde Tomer 63c79173b2 limit prekey uploads to 100 2025-04-25 10:06:42 -05:00
Ameya Lokare d2ad003891 Remove free memory and OS memory gauges 2025-04-25 10:05:29 -05:00
Chris Eager eb89773819 Remove unused parameter 2025-04-25 10:05:18 -05:00
Chris Eager 403abd84f6 Run test action on pull_request events 2025-04-25 10:05:08 -05:00
Jon Chambers f62f79c95c Add a counter for cases where clients use both an authenticated identity and UAK when fetching profiles 2025-04-24 11:47:43 -04:00
Jon Chambers 144c4c9223 Add a "sync" dimension to the "sent message" counter 2025-04-24 10:33:39 -05:00
Ravi Khadiwala ab4fc4f459 Add skip low urgency push experiment 2025-04-24 10:32:46 -05:00
Jonathan Klabunde Tomer 51569ce0a5
Use cached partition topology for metrics/logs 2025-04-24 08:29:58 -07:00
Jon Chambers f191c68efc Close remote connections only after all active server calls have completed 2025-04-22 17:00:48 -04:00
Jon Chambers bb8ce6d981 Introduce `ClosableEpoch` 2025-04-22 17:00:48 -04:00
Katherine e0ee75e0d0
Fix Daylight Savings bug in recommended notification time calculation 2025-04-22 16:56:10 -04:00
Jon Chambers 1ef3a230a1 Tag queue size distribution with client platform 2025-04-22 16:55:16 -04:00
Jon Chambers b1805d4bf1 Add a "persisted bytes" counter 2025-04-22 16:55:16 -04:00
Jon Chambers cac979c7fd Count individual persisted messages 2025-04-22 16:55:16 -04:00
Jon Chambers 4072dcdda5 Introduce `DevicePlatformUtil` 2025-04-22 16:55:16 -04:00
Jonathan Klabunde Tomer ed382fff6d log slot number and shard host of message persister failures 2025-04-22 16:55:16 -04:00
Jon Chambers 23bb8277d5 Update to the latest version of the spam filter 2025-04-18 15:56:17 -04:00
Jon Chambers 8099d6465c
Clarify guarantees around remote channnel/request attribute presence 2025-04-18 15:44:21 -04:00
Jon Chambers 28a0b9e84e
Include a TURN credential TTL for clients in `GetCallingRelaysResponse` 2025-04-17 10:30:58 -04:00
Chris Eager 9287aaf7ce Add app info to Stripe API calls 2025-04-17 09:30:34 -05:00
Chris Eager 0585f862cb Add regression test for set profile badges calculation 2025-04-17 09:29:11 -05:00
Chris Eager 7cac6f6f72 Remove extraneous account fetch in POST /v1/donation/redeem-receipt 2025-04-17 09:28:57 -05:00
Jon Chambers 57be4d798b Add a counter for attempts to send empty message lists 2025-04-17 10:27:46 -04:00
Jon Chambers 05c74f1997 Simplify `UserAgentUtil` 2025-04-17 10:27:24 -04:00
Jon Chambers f5e49b6db7 Convert `UserAgent` to a record 2025-04-15 14:58:09 -04:00
Jon Chambers 3c40e72d27
Fix registration ID map construction when changing numbers 2025-04-15 14:57:28 -04:00
Ravi Khadiwala 2f2ae7cec5 simplify story tag calculation 2025-04-11 14:04:09 -05:00
Chris Eager b236b53dc3 set profile: move updated badge calculation into account updater lambda 2025-04-11 14:03:05 -05:00
Katherine eb71e30046
Update to protobuf 4.x 2025-04-10 13:05:23 -04:00
Jon Chambers aa5fd52302 Explicitly pass sync message sender device ID as an argument to `sendMessage` 2025-04-10 11:40:32 -04:00
371 changed files with 11409 additions and 8011 deletions

View File

@ -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

View File

@ -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

View File

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

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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
View File

@ -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>

View File

@ -66,7 +66,6 @@ cdn.accessSecret: test # AWS Access Secret
cdn3StorageManager.clientSecret: test
unidentifiedDelivery.certificate: ABCD1234
unidentifiedDelivery.privateKey: ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AAAAAAA
keyTransparencyService.clientPrivateKey: |

View File

@ -138,6 +138,8 @@ dynamoDbTables:
tableName: Example_EC_Signed_Pre_Keys
pqKeys:
tableName: Example_PQ_Keys
pagedPqKeys:
tableName: Example_PQ_Paged_Keys
pqLastResortKeys:
tableName: Example_PQ_Last_Resort_Keys
messages:
@ -174,6 +176,10 @@ dynamoDbTables:
verificationSessions:
tableName: Example_VerificationSessions
pagedSingleUseKEMPreKeyStore:
bucket: preKeyBucket # S3 Bucket name
region: us-west-2 # AWS region
cacheCluster: # Redis server configuration for cache cluster
configurationUri: redis://redis.example.com:6379/
@ -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

View File

@ -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>

View File

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

View File

@ -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;
}

View File

@ -85,7 +85,6 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator
import org.whispersystems.textsecuregcm.auth.IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter;
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener;
import org.whispersystems.textsecuregcm.auth.grpc.ProhibitAuthenticationInterceptor;
import org.whispersystems.textsecuregcm.auth.grpc.RequireAuthenticationInterceptor;
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
@ -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));

View File

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

View File

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

View File

@ -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) {
}

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

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

View File

@ -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) {

View File

@ -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())));

View File

@ -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 {

View File

@ -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) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -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,

View File

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

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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) {}

View File

@ -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) {}
}

View File

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

View File

@ -55,8 +55,7 @@ import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
import org.whispersystems.websocket.auth.Mutable;
import org.whispersystems.websocket.auth.ReadOnly;
import org.whispersystems.textsecuregcm.storage.Device;
@Path("/v2/accounts")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Account")
@ -101,12 +100,12 @@ public class AccountControllerV2 {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public AccountIdentityResponse changeNumber(@Mutable @Auth final AuthenticatedDevice authenticatedDevice,
@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(

View File

@ -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);
}

View File

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

View File

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

View File

@ -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
) {
}
}

View File

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

View File

@ -25,6 +25,7 @@ import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
@ -40,12 +41,14 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker;
import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker.ChallengeConstraints;
import org.whispersystems.websocket.auth.ReadOnly;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
@Path("/v1/challenge")
@Tag(name = "Challenge")
public class ChallengeController {
private final AccountsManager accountsManager;
private final RateLimitChallengeManager rateLimitChallengeManager;
private final ChallengeConstraintChecker challengeConstraintChecker;
@ -53,8 +56,10 @@ public class ChallengeController {
private static final String CHALLENGE_TYPE_TAG = "type";
public ChallengeController(
final AccountsManager accountsManager,
final RateLimitChallengeManager rateLimitChallengeManager,
final ChallengeConstraintChecker challengeConstraintChecker) {
this.accountsManager = accountsManager;
this.rateLimitChallengeManager = rateLimitChallengeManager;
this.challengeConstraintChecker = challengeConstraintChecker;
}
@ -77,15 +82,18 @@ public class ChallengeController {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public Response handleChallengeResponse(@ReadOnly @Auth final AuthenticatedDevice auth,
public Response handleChallengeResponse(@Auth final AuthenticatedDevice auth,
@Valid final AnswerChallengeRequest answerRequest,
@Context ContainerRequestContext requestContext,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException {
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent));
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
requestContext, auth.getAccount());
requestContext, account);
try {
if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {
tags = tags.and(CHALLENGE_TYPE_TAG, "push");
@ -93,14 +101,14 @@ public class ChallengeController {
if (!constraints.pushPermitted()) {
return Response.status(429).build();
}
rateLimitChallengeManager.answerPushChallenge(auth.getAccount(), pushChallengeRequest.getChallenge());
rateLimitChallengeManager.answerPushChallenge(account, pushChallengeRequest.getChallenge());
} else if (answerRequest instanceof AnswerCaptchaChallengeRequest captchaChallengeRequest) {
tags = tags.and(CHALLENGE_TYPE_TAG, "captcha");
final String remoteAddress = (String) requestContext.getProperty(
RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
boolean success = rateLimitChallengeManager.answerCaptchaChallenge(
auth.getAccount(),
account,
captchaChallengeRequest.getCaptcha(),
remoteAddress,
userAgent,
@ -163,15 +171,18 @@ public class ChallengeController {
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public Response requestPushChallenge(@ReadOnly @Auth final AuthenticatedDevice auth,
public Response requestPushChallenge(@Auth final AuthenticatedDevice auth,
@Context ContainerRequestContext requestContext) {
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
requestContext, auth.getAccount());
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(requestContext, account);
if (!constraints.pushPermitted()) {
return Response.status(429).build();
}
try {
rateLimitChallengeManager.sendPushChallenge(auth.getAccount());
rateLimitChallengeManager.sendPushChallenge(account);
return Response.status(200).build();
} catch (final NotPushRegisteredException e) {
return Response.status(404).build();

View File

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

View File

@ -34,7 +34,6 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.time.Duration;
@ -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"));
}

View File

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

View File

@ -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());
}

View File

@ -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) {
}

View File

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

View File

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

View File

@ -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,

View File

@ -105,6 +105,7 @@ import org.whispersystems.textsecuregcm.spam.SpamChecker;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
@ -112,7 +113,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
import org.whispersystems.websocket.WebsocketHeaders;
import org.whispersystems.websocket.auth.ReadOnly;
import reactor.core.scheduler.Scheduler;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@ -236,7 +236,7 @@ public class MessageController {
@ApiResponse(
responseCode="428",
description="The sender should complete a challenge before proceeding")
public Response sendMessage(@ReadOnly @Auth final Optional<AuthenticatedDevice> source,
public Response sendMessage(@Auth final Optional<AuthenticatedDevice> source,
@Parameter(description="The recipient's unidentified access key")
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) final Optional<Anonymous> accessKey,
@ -274,12 +274,14 @@ public class MessageController {
sendStoryMessage(destinationIdentifier, messages, context);
} else if (source.isPresent()) {
final AuthenticatedDevice authenticatedDevice = source.get();
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
if (authenticatedDevice.getAccount().isIdentifiedBy(destinationIdentifier)) {
if (account.isIdentifiedBy(destinationIdentifier)) {
needsSync = false;
sendSyncMessage(source.get(), destinationIdentifier, messages, context);
sendSyncMessage(source.get(), account, destinationIdentifier, messages, context);
} else {
needsSync = authenticatedDevice.getAccount().getDevices().size() > 1;
needsSync = account.getDevices().size() > 1;
sendIdentifiedSenderIndividualMessage(authenticatedDevice, destinationIdentifier, messages, context);
}
} else {
@ -302,7 +304,7 @@ public class MessageController {
final Account destination =
accountsManager.getByServiceIdentifier(destinationIdentifier).orElseThrow(NotFoundException::new);
rateLimiters.getMessagesLimiter().validate(source.getAccount().getUuid(), destination.getUuid());
rateLimiters.getMessagesLimiter().validate(source.accountIdentifier(), destination.getUuid());
sendIndividualMessage(destination,
destinationIdentifier,
@ -314,6 +316,7 @@ public class MessageController {
}
private void sendSyncMessage(final AuthenticatedDevice source,
final Account sourceAccount,
final ServiceIdentifier destinationIdentifier,
final IncomingMessageList messages,
final ContainerRequestContext context)
@ -323,7 +326,7 @@ public class MessageController {
throw new WebApplicationException(Status.FORBIDDEN);
}
sendIndividualMessage(source.getAccount(),
sendIndividualMessage(sourceAccount,
destinationIdentifier,
source,
messages,
@ -420,8 +423,8 @@ public class MessageController {
try {
return message.toEnvelope(
destinationIdentifier,
sender != null ? sender.getAccount() : null,
sender != null ? sender.getAuthenticatedDevice().getId() : null,
sender != null ? new AciServiceIdentifier(sender.accountIdentifier()) : null,
sender != null ? sender.deviceId() : null,
messages.timestamp() == 0 ? System.currentTimeMillis() : messages.timestamp(),
isStory,
messages.online(),
@ -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 =

View File

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

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
@ -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());

View File

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

View File

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

View File

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

View File

@ -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());
}

View File

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

View File

@ -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;
}

View File

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

View File

@ -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);
}

View File

@ -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")

View File

@ -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);
}

View File

@ -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) {

View File

@ -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)

View File

@ -54,6 +54,7 @@ public record KeyTransparencySearchRequest(
@Positive long distinguishedTreeHeadSize
) {
@AssertTrue
@Schema(hidden = true)
public boolean isUnidentifiedAccessKeyProvidedWithE164() {
return unidentifiedAccessKey.isPresent() == e164.isPresent();
}

View File

@ -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();

View File

@ -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())

View File

@ -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");
}
}

View File

@ -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);

View File

@ -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

View File

@ -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();

View File

@ -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 {
// Its a little counter-intuitive, but this compact constructor allows a default value
// to be used when one isnt specified, allowing the field to still be
// validated as @NotNull
if (preKeys == null) {
preKeys = List.of();
}
if (pqPreKeys == null) {
pqPreKeys = List.of();
}
}
}

View File

@ -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());
}

View File

@ -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();
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}

View File

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

View File

@ -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 {
}

View File

@ -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();
}
};
}
}

View File

@ -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;
};
}
}

View File

@ -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